From e089d1bd525cc5e93cb47a5cd339121c96f20a4b Mon Sep 17 00:00:00 2001 From: Taylor Marvin Date: Mon, 2 Mar 2026 01:11:47 -0800 Subject: [PATCH 01/37] In progress refactor - restart development initial commit --- .config/dotnet-tools.json | 11 + .dockerignore | 24 + .editorconfig | 393 + .github/workflows/ci.yml | 65 + .gitignore | 157 +- Directory.Build.props | 22 + Directory.Packages.props | 70 + GitVersion.yml | 41 + LICENSE | 42 +- README.md | 442 +- Werkr.slnx | 65 + docker-compose.yml | 171 + docs/CODE_OF_CONDUCT.md | 256 +- docs/SECURITY.md | 34 +- .../fonts/glyphicons-halflings-regular.svg | 574 +- .../Werkr/partials/affix.tmpl.partial | 80 +- .../Werkr/partials/footer.tmpl.partial | 74 +- .../Werkr/partials/head.tmpl.partial | 38 +- docs/docfx/templates/Werkr/styles/lunr.js | 5848 ++++---- .../templates/Werkr/styles/toggle-theme.js | 68 +- docs/images/WerkrAgentMsiDialog.bmp | Bin 0 -> 615414 bytes docs/images/WerkrLogo.ico | Bin 0 -> 20741 bytes .../images/WerkrLogoWithText_GithubBanner.png | Bin 0 -> 104366 bytes docs/images/WerkrMsiBanner.bmp | Bin 0 -> 114526 bytes docs/images/WerkrServerMsiDialog.bmp | Bin 0 -> 615414 bytes global.json | 9 + scripts/docker-build.ps1 | 292 + scripts/docker-build.sh | 78 + scripts/publish.ps1 | 1170 ++ src/Installer/Msi/Agent/Agent.wixproj | 59 + src/Installer/Msi/Agent/Agent.wxs | 411 + src/Installer/Msi/Agent/Package.en-us.wxl | 4 + .../Msi/Agent/images/WerkrAgentMsiDialog.bmp | Bin 0 -> 615414 bytes .../Msi/Agent/images/WerkrMsiBanner.bmp | Bin 0 -> 114526 bytes src/Installer/Msi/Agent/packages.lock.json | 23 + .../Msi/CustomActions/CustomAction.config | 6 + .../Msi/CustomActions/CustomAction.cs | 176 + .../Werkr.Installer.Msi.CustomActions.csproj | 42 + .../Msi/CustomActions/packages.lock.json | 107 + src/Installer/Msi/Server/Package.en-us.wxl | 4 + src/Installer/Msi/Server/Server.wixproj | 64 + src/Installer/Msi/Server/Server.wxs | 365 + .../Msi/Server/images/WerkrMsiBanner.bmp | Bin 0 -> 114526 bytes .../Server/images/WerkrServerMsiDialog.bmp | Bin 0 -> 615414 bytes src/Installer/Msi/Server/packages.lock.json | 23 + .../Helpers/AllowAllPathValidator.cs | 16 + .../Helpers/AllowPrefixValidator.cs | 41 + .../Helpers/DenyAllPathValidator.cs | 21 + .../Werkr.Tests.Agent/Helpers/FailHandler.cs | 30 + .../Helpers/MockServerStreamWriter.cs | 29 + .../Werkr.Tests.Agent/Helpers/SlowHandler.cs | 33 + .../Helpers/SuccessHandler.cs | 30 + .../Helpers/TestActionDescriptor.cs | 41 + .../Helpers/TestFilePathResolver.cs | 21 + .../Helpers/TestServerCallContext.cs | 47 + .../Werkr.Tests.Agent/Helpers/ThrowHandler.cs | 26 + .../BearerTokenInterceptorTests.cs | 230 + .../Operators/ActionOperatorTests.cs | 284 + .../Actions/ClearContentHandlerTests.cs | 77 + .../Operators/Actions/CopyFileHandlerTests.cs | 139 + .../Actions/CreateDirectoryHandlerTests.cs | 88 + .../Actions/CreateFileHandlerTests.cs | 113 + .../Actions/DeleteFileHandlerTests.cs | 91 + .../Operators/Actions/MoveFileHandlerTests.cs | 97 + .../Actions/RenameFileHandlerTests.cs | 108 + .../Actions/StartProcessHandlerTests.cs | 170 + .../Actions/StopProcessHandlerTests.cs | 90 + .../Actions/TestExistsHandlerTests.cs | 122 + .../Actions/WriteContentHandlerTests.cs | 89 + .../Operators/PSHostTests.cs | 264 + .../Operators/PwshOperatorTests.cs | 117 + .../Operators/SystemShellOperatorTests.cs | 137 + .../ScheduleEvaluatorServiceTests.cs | 338 + .../Security/FilePathResolverTests.cs | 114 + .../Security/PathAllowlistValidatorTests.cs | 154 + .../Services/OperatorOutputAdapterTests.cs | 122 + .../Services/PwshServiceTests.cs | 108 + .../Services/SystemShellServiceTests.cs | 108 + .../Werkr.Tests.Agent.csproj | 21 + src/Test/Werkr.Tests.Agent/packages.lock.json | 1638 +++ .../Unit/Collections/LoopingListTests.cs | 121 + .../AgentConnectionManagerTests.cs | 186 + .../Communication/CommandDispatcherTests.cs | 151 + .../Unit/Communication/KeyRotationTests.cs | 223 + .../Unit/Communication/NullEncryptionTests.cs | 53 + .../Communication/PayloadEncryptorTests.cs | 171 + .../ConfigurationExtensionsTests.cs | 117 + .../Cryptography/EncryptionProviderTests.cs | 179 + .../Cryptography/HybridEncryptionTests.cs | 66 + .../Cryptography/PlatformValidationTests.cs | 12 + .../Unit/Ranges/IntRangeTests.cs | 113 + .../Unit/Ranges/RangeOfDaysTests.cs | 87 + .../Unit/Ranges/RangeOfMonthsTests.cs | 97 + .../Unit/Ranges/RangeOfWeekNumsTests.cs | 85 + .../BundleExpirationServiceTests.cs | 157 + .../RegistrationBundleGeneratorTests.cs | 65 + .../RegistrationBundlePayloadTests.cs | 52 + .../Registration/RegistrationServiceTests.cs | 229 + .../Scheduling/CalendarEnumExtensionsTests.cs | 245 + .../Unit/Scheduling/HolidayCalculatorTests.cs | 471 + .../Scheduling/HolidayCalendarServiceTests.cs | 483 + .../Scheduling/HolidayDateServiceTests.cs | 287 + .../Unit/Scheduling/HolidayFilterTests.cs | 310 + .../Scheduling/HolidayRuleValidatorTests.cs | 305 + .../Scheduling/ScheduleCalculatorTests.cs | 1853 +++ .../ScheduleDescriptionBuilderTests.cs | 164 + .../Unit/Scheduling/ScheduleServiceTests.cs | 432 + .../Unit/Security/SecretStoreTests.cs | 46 + .../Unit/Tasks/AgentResolverTests.cs | 151 + .../Tasks/SuccessCriteriaEvaluatorTests.cs | 234 + .../Unit/Tasks/TaskServiceTests.cs | 209 + .../Unit/Workflows/ConditionEvaluatorTests.cs | 214 + .../Unit/Workflows/WorkflowExecutorTests.cs | 843 ++ .../Unit/Workflows/WorkflowServiceTests.cs | 374 + .../Werkr.Tests.Data/Werkr.Tests.Data.csproj | 21 + src/Test/Werkr.Tests.Data/packages.lock.json | 1015 ++ .../ActionParameterRegistryTests.cs | 116 + .../AuthorizationAttributeTests.cs | 11 + .../Authorization/PageAuthorizationTests.cs | 141 + .../Identity/ApiKeyServiceTests.cs | 174 + .../Identity/IdentityFlowTests.cs | 247 + .../Identity/IdentityOptionsTests.cs | 73 + .../Identity/IdentitySeederTests.cs | 134 + .../Identity/JwtTokenServiceTests.cs | 191 + .../Identity/PermissionServiceTests.cs | 127 + .../Identity/SecurityTests.cs | 165 + .../Identity/UrlValidatorTests.cs | 33 + .../Identity/UserManagementTests.cs | 263 + .../Identity/WerkrCookieAuthEventsTests.cs | 208 + .../Pages/AgentDetailTests.cs | 97 + .../Pages/DashboardTests.cs | 148 + .../Werkr.Tests.Server/Pages/DtoModelTests.cs | 106 + .../Pages/PageDiscoveryTests.cs | 67 + .../Werkr.Tests.Server/Pages/SettingsTests.cs | 59 + src/Test/Werkr.Tests.Server/Placeholder.cs | 12 + .../Werkr.Tests.Server.csproj | 24 + .../Werkr.Tests.Server/packages.lock.json | 1028 ++ src/Test/Werkr.Tests/AppHostFixture.cs | 239 + .../ActionDispatchIntegrationTests.cs | 444 + .../HolidayCalendarIntegrationTests.cs | 584 + .../Integration/ScheduleExecutionTests.cs | 247 + .../Integration/ScheduledActionTests.cs | 385 + .../Integration/WorkflowIntegrationTests.cs | 371 + .../Werkr.Tests/ScheduleIntegrationTests.cs | 23 + src/Test/Werkr.Tests/WebTests.cs | 25 + src/Test/Werkr.Tests/Werkr.Tests.csproj | 36 + src/Test/Werkr.Tests/Werkr.Tests.runsettings | 14 + src/Test/Werkr.Tests/packages.lock.json | 1268 ++ .../Communication/AgentGrpcClientFactory.cs | 218 + src/Werkr.Agent/Dockerfile | 112 + .../Interceptors/BearerTokenInterceptor.cs | 105 + ...ctionHandlerServiceCollectionExtensions.cs | 30 + src/Werkr.Agent/Operators/ActionOperator.cs | 189 + .../Operators/Actions/ActionJson.cs | 12 + .../Operators/Actions/ClearContentHandler.cs | 58 + .../Operators/Actions/CopyFileHandler.cs | 113 + .../Actions/CreateDirectoryHandler.cs | 65 + .../Operators/Actions/CreateFileHandler.cs | 86 + .../Operators/Actions/DeleteFileHandler.cs | 86 + .../Operators/Actions/MoveFileHandler.cs | 98 + .../Operators/Actions/RenameFileHandler.cs | 92 + .../Operators/Actions/StartProcessHandler.cs | 143 + .../Operators/Actions/StopProcessHandler.cs | 92 + .../Operators/Actions/TestExistsHandler.cs | 65 + .../Operators/Actions/WriteContentHandler.cs | 63 + src/Werkr.Agent/Operators/PwshOperator.cs | 255 + .../Operators/SystemShellOperator.cs | 157 + src/Werkr.Agent/Operators/WerkrPSHost.cs | 64 + .../Operators/WerkrPSHostRawUserInterface.cs | 104 + .../Operators/WerkrPSHostUserInterface.cs | 101 + src/Werkr.Agent/Program.cs | 205 + src/Werkr.Agent/Protos/Action.proto | 20 + src/Werkr.Agent/Protos/GrpcLogMsg.proto | 13 + src/Werkr.Agent/Protos/Shell.proto | 34 + .../Registration/AgentRegistrationHandler.cs | 157 + .../Registration/RegistrationEndpoints.cs | 186 + .../Scheduling/AgentJobOutputWriter.cs | 110 + .../Scheduling/ScheduleEvaluatorService.cs | 957 ++ src/Werkr.Agent/Security/FilePathResolver.cs | 64 + src/Werkr.Agent/Security/NativeMethods.cs | 79 + .../Security/PathAllowlistValidator.cs | 153 + src/Werkr.Agent/Services/ActionService.cs | 60 + .../Services/ConnectionManagementService.cs | 224 + .../Services/OperatorOutputAdapter.cs | 46 + .../Services/OutputFetchService.cs | 100 + src/Werkr.Agent/Services/PwshService.cs | 99 + .../Services/ScheduleInvalidationService.cs | 76 + .../Services/SystemShellService.cs | 99 + src/Werkr.Agent/Werkr.Agent.csproj | 45 + src/Werkr.Agent/appsettings.Development.json | 13 + src/Werkr.Agent/appsettings.json | 41 + src/Werkr.Agent/packages.lock.json | 1159 ++ .../ClaimsPermissionAuthorizationHandler.cs | 36 + src/Werkr.Api/Dockerfile | 106 + src/Werkr.Api/Endpoints/AgentEndpoints.cs | 517 + src/Werkr.Api/Endpoints/AuthProxyEndpoints.cs | 47 + .../Endpoints/DiagnosticsEndpoints.cs | 36 + .../Endpoints/HolidayCalendarEndpoints.cs | 514 + src/Werkr.Api/Endpoints/JobEndpoints.cs | 118 + .../Endpoints/RegistrationEndpoints.cs | 37 + src/Werkr.Api/Endpoints/ScheduleEndpoints.cs | 127 + src/Werkr.Api/Endpoints/SettingsEndpoints.cs | 83 + src/Werkr.Api/Endpoints/ShellEndpoints.cs | 64 + src/Werkr.Api/Endpoints/StatusEndpoints.cs | 17 + src/Werkr.Api/Endpoints/TaskEndpoints.cs | 125 + src/Werkr.Api/Endpoints/WorkflowEndpoints.cs | 307 + .../AgentBearerTokenInterceptor.cs | 130 + src/Werkr.Api/Models/HolidayCalendarMapper.cs | 170 + src/Werkr.Api/Models/ScheduleMapper.cs | 112 + src/Werkr.Api/Models/TaskMapper.cs | 177 + src/Werkr.Api/Models/WorkflowMapper.cs | 89 + src/Werkr.Api/Program.cs | 248 + src/Werkr.Api/Protos/Registration.proto | 38 + .../Services/AuditLogCleanupService.cs | 63 + src/Werkr.Api/Services/AuditLogOptions.cs | 13 + .../Services/HolidayCalendarService.cs | 406 + .../Services/JobReportingGrpcService.cs | 104 + .../Services/RegistrationGrpcService.cs | 58 + .../ScheduleInvalidationDispatcher.cs | 134 + .../Services/ScheduleSyncGrpcService.cs | 360 + .../Services/WorkflowExecutionGrpcService.cs | 108 + src/Werkr.Api/Werkr.Api.csproj | 37 + src/Werkr.Api/Werkr.Api.http | 6 + src/Werkr.Api/appsettings.Development.json | 18 + src/Werkr.Api/appsettings.json | 28 + src/Werkr.Api/packages.lock.json | 554 + src/Werkr.AppHost/AppHost.cs | 42 + src/Werkr.AppHost/Werkr.AppHost.csproj | 18 + .../appsettings.Development.json | 8 + src/Werkr.AppHost/appsettings.json | 9 + src/Werkr.AppHost/packages.lock.json | 656 + .../AgentSettings.cs | 25 + .../JobOutputOptions.cs | 21 + .../PowerShellSettings.cs | 14 + .../ServerSettings.cs | 16 + src/Werkr.Common.Configuration/UiSettings.cs | 16 + .../Werkr.Common.Configuration.csproj | 12 + .../WerkrConfiguration.cs | 25 + .../packages.lock.json | 21 + .../Auth/JwtValidationConfigurator.cs | 35 + src/Werkr.Common/Auth/Permission.cs | 25 + .../Auth/PermissionPolicyExtensions.cs | 31 + .../Auth/PermissionRequirement.cs | 14 + src/Werkr.Common/Auth/Policies.cs | 25 + src/Werkr.Common/Auth/WerkrClaimTypes.cs | 15 + .../RegistryConfigurationExtensions.cs | 27 + .../Registry/RegistryConfigurationProvider.cs | 84 + .../Registry/RegistryConfigurationSource.cs | 28 + src/Werkr.Common/DatabaseProvider.cs | 10 + .../ConfigurationBuilderExtensions.cs | 42 + .../Models/ActionOperatorConfiguration.cs | 18 + .../Models/Actions/ActionDescriptor.cs | 20 + .../Models/Actions/ClearContentParameters.cs | 7 + .../Models/Actions/CopyFileParameters.cs | 16 + .../Actions/CreateDirectoryParameters.cs | 7 + .../Models/Actions/CreateFileParameters.cs | 19 + .../Models/Actions/DeleteFileParameters.cs | 13 + .../Models/Actions/MoveFileParameters.cs | 13 + .../Models/Actions/RenameFileParameters.cs | 13 + .../Models/Actions/StartProcessParameters.cs | 19 + .../Models/Actions/StopProcessParameters.cs | 13 + .../Models/Actions/TestExistsParameters.cs | 10 + .../Models/Actions/WriteContentParameters.cs | 16 + src/Werkr.Common/Models/AgentActivityDto.cs | 9 + src/Werkr.Common/Models/AgentConnectionDto.cs | 21 + src/Werkr.Common/Models/AgentDetailDto.cs | 13 + src/Werkr.Common/Models/AgentHealthDto.cs | 11 + src/Werkr.Common/Models/AgentListDto.cs | 16 + src/Werkr.Common/Models/AgentStatus.cs | 16 + .../Models/AllowedPathsConfiguration.cs | 25 + .../Models/ApiKeyCreateRequest.cs | 6 + .../Models/ApiKeyCreateResponse.cs | 14 + src/Werkr.Common/Models/ApiKeyDto.cs | 13 + src/Werkr.Common/Models/ConnectionStatus.cs | 16 + .../Models/DagValidationResult.cs | 6 + src/Werkr.Common/Models/DailyRecurrenceDto.cs | 5 + src/Werkr.Common/Models/DatabaseHealthDto.cs | 10 + .../Models/ExecuteCommandRequest.cs | 10 + .../Models/ExecuteCommandResponse.cs | 10 + .../Models/ExpirationDateTimeDto.cs | 7 + .../Holidays/AttachHolidayCalendarRequest.cs | 6 + .../Holidays/BulkHolidayDateCreateRequest.cs | 5 + .../BulkScheduleHolidayDatesResponse.cs | 5 + .../Holidays/CloneHolidayCalendarRequest.cs | 5 + .../Holidays/HolidayCalendarCreateRequest.cs | 6 + .../Models/Holidays/HolidayCalendarDto.cs | 12 + .../Holidays/HolidayCalendarSummaryDto.cs | 10 + .../Holidays/HolidayCalendarUpdateRequest.cs | 6 + .../Holidays/HolidayDateCreateRequest.cs | 9 + .../Models/Holidays/HolidayDateDto.cs | 14 + .../Models/Holidays/HolidayPreviewResponse.cs | 8 + .../Holidays/HolidayRuleCreateRequest.cs | 16 + .../Models/Holidays/HolidayRuleDto.cs | 18 + .../Holidays/HolidayRuleUpdateRequest.cs | 16 + .../Models/Holidays/RulePreviewRequest.cs | 16 + .../Models/Holidays/RulePreviewResponse.cs | 7 + .../Holidays/ScheduleAuditLogCreateRequest.cs | 7 + .../Models/Holidays/ScheduleAuditLogDto.cs | 11 + .../Holidays/ScheduleHolidayCalendarDto.cs | 7 + .../Holidays/ScheduleHolidayDatesResponse.cs | 8 + .../Holidays/SuppressedOccurrenceDto.cs | 7 + src/Werkr.Common/Models/JobDto.cs | 15 + src/Werkr.Common/Models/JobListDto.cs | 14 + .../Models/MonthlyRecurrenceDto.cs | 12 + .../Models/NotifyUrlChangeRequest.cs | 4 + .../Models/NotifyUrlChangeResponse.cs | 7 + .../Models/OccurrencePreviewResponse.cs | 12 + src/Werkr.Common/Models/OperatorOutputLine.cs | 13 + src/Werkr.Common/Models/OperatorType.cs | 13 + src/Werkr.Common/Models/PathType.cs | 16 + .../Models/RegistrationGenerateRequest.cs | 12 + .../Models/RegistrationGenerateResponse.cs | 10 + src/Werkr.Common/Models/RegistrationStatus.cs | 16 + src/Werkr.Common/Models/RepeatOptionsDto.cs | 6 + .../Models/ScheduleCreateRequest.cs | 12 + src/Werkr.Common/Models/ScheduleDto.cs | 13 + .../Models/ScheduleUpdateRequest.cs | 12 + src/Werkr.Common/Models/StartDateTimeDto.cs | 7 + src/Werkr.Common/Models/StepDependencyDto.cs | 4 + .../Models/StepDependencyRequest.cs | 4 + src/Werkr.Common/Models/TaskCreateRequest.cs | 17 + src/Werkr.Common/Models/TaskDto.cs | 20 + src/Werkr.Common/Models/TaskRunRequest.cs | 4 + .../Models/TaskSetEnabledRequest.cs | 4 + src/Werkr.Common/Models/TaskUpdateRequest.cs | 17 + src/Werkr.Common/Models/TokenRequest.cs | 4 + src/Werkr.Common/Models/TokenResponse.cs | 6 + src/Werkr.Common/Models/UpdateAgentRequest.cs | 6 + .../Models/UpdateAgentStatusRequest.cs | 4 + .../Models/UpdateAgentTagsRequest.cs | 4 + .../Models/WeeklyRecurrenceDto.cs | 6 + .../Models/WorkflowCreateRequest.cs | 8 + src/Werkr.Common/Models/WorkflowDto.cs | 10 + .../Models/WorkflowRunDetailDto.cs | 10 + src/Werkr.Common/Models/WorkflowRunDto.cs | 9 + src/Werkr.Common/Models/WorkflowRunRequest.cs | 4 + .../Models/WorkflowSetEnabledRequest.cs | 4 + .../Models/WorkflowStepCreateRequest.cs | 11 + src/Werkr.Common/Models/WorkflowStepDto.cs | 14 + .../Models/WorkflowStepStatusUpdate.cs | 10 + .../Models/WorkflowStepUpdateRequest.cs | 10 + .../Models/WorkflowUpdateRequest.cs | 8 + .../Protos/ConnectionManagement.proto | 71 + .../Protos/EncryptedEnvelope.proto | 24 + src/Werkr.Common/Protos/JobReport.proto | 46 + src/Werkr.Common/Protos/OutputFetch.proto | 28 + .../Protos/ScheduleInvalidation.proto | 23 + src/Werkr.Common/Protos/ScheduleSync.proto | 149 + .../Protos/WorkflowExecution.proto | 28 + .../Rendering/AnsiHtmlConverter.cs | 231 + src/Werkr.Common/Werkr.Common.csproj | 25 + src/Werkr.Common/packages.lock.json | 195 + .../Communication/AgentConnectionManager.cs | 144 + .../Communication/CommandDispatchFailure.cs | 24 + .../Communication/CommandDispatcher.cs | 341 + .../CommandDispatcherException.cs | 48 + .../Communication/GrpcOutputReader.cs | 38 + .../Communication/ICommandDispatcher.cs | 51 + .../Communication/KeyRotationService.cs | 181 + .../Communication/OperatorOutput.cs | 30 + .../Communication/PayloadEncryptor.cs | 98 + .../Cryptography/EncryptionProvider.cs | 355 + .../KeyInfo/AesGcmDecryptionData.cs | 30 + .../KeyInfo/AesGcmDecryptionNote.cs | 41 + .../Cryptography/KeyInfo/RSAKeyPair.cs | 61 + .../Cryptography/WerkrCryptoException.cs | 17 + .../Health/AgentHealthCheckService.cs | 154 + .../Operators/ActionOperatorResult.cs | 10 + src/Werkr.Core/Operators/IActionHandler.cs | 41 + src/Werkr.Core/Operators/IActionOperator.cs | 25 + src/Werkr.Core/Operators/IOperatorResult.cs | 18 + src/Werkr.Core/Operators/IShellOperator.cs | 31 + src/Werkr.Core/Operators/OperatorExecution.cs | 16 + .../Operators/PwshOperatorResult.cs | 22 + .../Operators/ShellOperatorResult.cs | 17 + .../Registration/BundleExpirationService.cs | 70 + .../Models/AgentRegistrationResult.cs | 14 + .../Models/RegistrationBundlePayload.cs | 57 + .../Models/RegistrationResponsePayload.cs | 16 + .../RegistrationBundleGenerator.cs | 66 + .../Registration/RegistrationService.cs | 157 + .../Scheduling/HolidayCalculator.cs | 153 + .../Scheduling/HolidayDateService.cs | 128 + .../Scheduling/ScheduleCalculator.cs | 848 ++ .../Scheduling/ScheduleDescriptionBuilder.cs | 180 + .../Scheduling/ScheduleOccurrenceResult.cs | 10 + src/Werkr.Core/Scheduling/ScheduleService.cs | 324 + .../Scheduling/SuppressedOccurrence.cs | 12 + src/Werkr.Core/Security/IFilePathResolver.cs | 37 + .../Security/IPathAllowlistValidator.cs | 43 + src/Werkr.Core/Security/ISecretStore.cs | 21 + src/Werkr.Core/Security/LinuxSecretStore.cs | 139 + src/Werkr.Core/Security/MacOsSecretStore.cs | 70 + src/Werkr.Core/Security/SecretStoreFactory.cs | 27 + src/Werkr.Core/Security/WindowsSecretStore.cs | 59 + src/Werkr.Core/Tasks/AgentResolver.cs | 178 + src/Werkr.Core/Tasks/JobExecutionService.cs | 324 + src/Werkr.Core/Tasks/JobOutputWriter.cs | 103 + .../Tasks/SuccessCriteriaEvaluator.cs | 163 + src/Werkr.Core/Tasks/TaskService.cs | 170 + src/Werkr.Core/Werkr.Core.csproj | 30 + .../Workflows/ConditionEvaluator.cs | 95 + src/Werkr.Core/Workflows/WorkflowExecutor.cs | 431 + .../Workflows/WorkflowRunTracker.cs | 49 + src/Werkr.Core/Workflows/WorkflowService.cs | 422 + src/Werkr.Core/packages.lock.json | 428 + .../PermissionAuthorizationHandler.cs | 31 + src/Werkr.Data.Identity/Entities/ApiKey.cs | 59 + .../Entities/ConfigurationSettings.cs | 37 + .../Entities/RolePermission.cs | 27 + src/Werkr.Data.Identity/Entities/WerkrUser.cs | 23 + .../Extensions/IdentityExtensions.cs | 81 + .../PostgresIdentityDesignTimeFactory.cs | 20 + .../PostgresWerkrIdentityDbContext.cs | 13 + src/Werkr.Data.Identity/Roles/DefaultRoles.cs | 16 + .../Services/IPermissionService.cs | 34 + .../Services/PermissionService.cs | 73 + .../SqliteIdentityDesignTimeFactory.cs | 18 + .../SqliteWerkrIdentityDbContext.cs | 13 + .../Werkr.Data.Identity.csproj | 24 + .../WerkrIdentityDbContext.cs | 91 + src/Werkr.Data.Identity/packages.lock.json | 355 + src/Werkr.Data/Calendar/Enums/DaysOfWeek.cs | 22 + .../Calendar/Enums/HolidayCalendarMode.cs | 9 + .../Calendar/Enums/HolidayRuleType.cs | 11 + src/Werkr.Data/Calendar/Enums/Month.cs | 29 + src/Werkr.Data/Calendar/Enums/MonthsOfYear.cs | 35 + .../Calendar/Enums/ObservanceRule.cs | 13 + .../Calendar/Enums/WeekNumberWithinMonth.cs | 20 + .../Extensions/CalendarEnumExtensions.cs | 292 + src/Werkr.Data/Calendar/Models/Schedule.cs | 36 + .../Validation/HolidayRuleValidator.cs | 106 + .../Validation/MonthlyRecurrenceValidator.cs | 59 + .../Calendar/Validation/ScheduleValidator.cs | 74 + .../Calendar/Validation/WerkrTaskValidator.cs | 32 + src/Werkr.Data/Collections/LoopingList.cs | 128 + .../Encryption/EncryptedByteArrayConverter.cs | 17 + .../Encryption/EncryptedStringConverter.cs | 17 + .../Encryption/FieldEncryptionProvider.cs | 159 + src/Werkr.Data/Entities/ConcurrencyBase.cs | 23 + src/Werkr.Data/Entities/Interfaces/IKey.cs | 10 + .../Registration/RegisteredConnection.cs | 114 + .../Registration/RegistrationBundle.cs | 55 + .../Entities/Schedule/DailyRecurrence.cs | 21 + .../Entities/Schedule/DateTimeInfoBase.cs | 56 + .../Entities/Schedule/DbSchedule.cs | 46 + .../Schedule/ExpirationDateTimeInfo.cs | 20 + .../Entities/Schedule/HolidayCalendar.cs | 45 + .../Entities/Schedule/HolidayDate.cs | 55 + .../Entities/Schedule/HolidayRule.cs | 67 + .../Entities/Schedule/MonthlyRecurrence.cs | 47 + .../Entities/Schedule/ScheduleAuditLog.cs | 45 + .../Schedule/ScheduleHolidayCalendar.cs | 29 + .../Schedule/ScheduleRepeatOptions.cs | 24 + .../Entities/Schedule/StartDateTimeInfo.cs | 20 + .../Entities/Schedule/WeeklyRecurrence.cs | 26 + .../Settings/ConfigurationSettings.cs | 37 + .../Entities/Tasks/ErrorCategory.cs | 26 + .../Entities/Tasks/TaskActionType.cs | 17 + src/Werkr.Data/Entities/Tasks/WerkrJob.cs | 75 + src/Werkr.Data/Entities/Tasks/WerkrTask.cs | 96 + .../Entities/Workflows/ControlStatement.cs | 19 + .../Entities/Workflows/DependencyMode.cs | 11 + src/Werkr.Data/Entities/Workflows/Workflow.cs | 47 + .../Entities/Workflows/WorkflowRun.cs | 37 + .../Entities/Workflows/WorkflowRunStatus.cs | 15 + .../Entities/Workflows/WorkflowStep.cs | 78 + .../Workflows/WorkflowStepDependency.cs | 24 + src/Werkr.Data/PostgresDesignTimeFactory.cs | 19 + src/Werkr.Data/PostgresWerkrDbContext.cs | 13 + src/Werkr.Data/Ranges/IRange.cs | 13 + src/Werkr.Data/Ranges/IntRange.cs | 94 + src/Werkr.Data/Ranges/RangeOfDays.cs | 75 + src/Werkr.Data/Ranges/RangeOfMonths.cs | 74 + src/Werkr.Data/Ranges/RangeOfWeekNums.cs | 106 + .../Seeding/HolidayCalendarSeeder.cs | 279 + src/Werkr.Data/SqliteDesignTimeFactory.cs | 18 + src/Werkr.Data/SqliteWerkrDbContext.cs | 13 + src/Werkr.Data/Werkr.Data.csproj | 23 + src/Werkr.Data/WerkrDbContext.cs | 452 + src/Werkr.Data/WerkrDbContextExtensions.cs | 43 + src/Werkr.Data/packages.lock.json | 537 + src/Werkr.Server/Components/App.razor | 95 + .../Components/Layout/MainLayout.razor | 39 + .../Components/Layout/MainLayout.razor.css | 98 + .../Components/Layout/NavMenu.razor | 175 + .../Components/Layout/NavMenu.razor.css | 248 + .../Pages/Account/AccessDenied.razor | 17 + .../Pages/Account/ChangePassword.razor | 154 + .../Components/Pages/Account/Login.razor | 155 + .../Components/Pages/Account/Logout.razor | 14 + .../Pages/Account/Manage/Index.razor | 121 + .../Components/Pages/Account/Manage/Mfa.razor | 287 + .../Pages/Account/MfaRecovery.razor | 75 + .../Components/Pages/Account/MfaVerify.razor | 77 + .../Components/Pages/Admin/CreateUser.razor | 175 + .../Components/Pages/Admin/EditUser.razor | 293 + .../Components/Pages/Admin/Users.razor | 188 + .../Components/Pages/AgentDetail.razor | 264 + .../Components/Pages/Agents.razor | 124 + .../Components/Pages/Agents/Console.razor | 295 + .../Components/Pages/Agents/Index.razor | 122 + .../Components/Pages/ApiKeys.razor | 215 + .../Components/Pages/Dashboard/Calendar.razor | 348 + .../Pages/Dashboard/Calendar.razor.css | 167 + .../Components/Pages/Dashboard/TaskList.razor | 376 + src/Werkr.Server/Components/Pages/Error.razor | 38 + .../Pages/HolidayCalendars/Create.razor | 93 + .../Pages/HolidayCalendars/Detail.razor | 215 + .../Pages/HolidayCalendars/Edit.razor | 502 + .../Pages/HolidayCalendars/Index.razor | 153 + src/Werkr.Server/Components/Pages/Home.razor | 487 + .../Components/Pages/Jobs/Detail.razor | 133 + .../Components/Pages/Jobs/Index.razor | 176 + .../Components/Pages/Register.razor | 214 + .../Components/Pages/Schedules/Create.razor | 456 + .../Components/Pages/Schedules/Edit.razor | 580 + .../Components/Pages/Schedules/Index.razor | 181 + .../Components/Pages/Settings.razor | 349 + .../Components/Pages/Tasks/Create.razor | 220 + .../Components/Pages/Tasks/Edit.razor | 317 + .../Components/Pages/Tasks/Index.razor | 190 + .../Components/Pages/Workflows/Create.razor | 100 + .../Components/Pages/Workflows/Dag.razor | 107 + .../Components/Pages/Workflows/Edit.razor | 446 + .../Components/Pages/Workflows/Index.razor | 148 + .../Pages/Workflows/RunDetail.razor | 158 + .../Components/Pages/Workflows/Runs.razor | 132 + src/Werkr.Server/Components/Routes.razor | 8 + .../Shared/ActionParameterEditor.razor | 236 + .../Components/Shared/Breadcrumb.razor | 19 + .../Components/Shared/ConfirmDialog.razor | 55 + .../Components/Shared/DagView.razor | 195 + .../Components/Shared/DagView.razor.css | 22 + .../Components/Shared/EmptyState.razor | 11 + .../Components/Shared/LoadingSpinner.razor | 15 + .../Components/Shared/StatusBanner.razor | 54 + .../Components/Shared/TagInput.razor | 123 + .../Components/Shared/ToggleSwitch.razor | 29 + src/Werkr.Server/Components/_Imports.razor | 15 + src/Werkr.Server/Dockerfile | 106 + src/Werkr.Server/Endpoints/AuthEndpoints.cs | 101 + .../Helpers/AgentDisplayHelper.cs | 51 + src/Werkr.Server/Helpers/UrlValidator.cs | 29 + src/Werkr.Server/Identity/ApiKeyService.cs | 155 + .../Identity/AuthForwardingHandler.cs | 36 + src/Werkr.Server/Identity/IdentitySeeder.cs | 170 + src/Werkr.Server/Identity/JwtTokenService.cs | 150 + .../Identity/WerkrCookieAuthEvents.cs | 73 + src/Werkr.Server/Program.cs | 176 + .../Services/ActionParameterRegistry.cs | 141 + .../Services/AgentHealthMonitorService.cs | 86 + .../Services/ServerConfigCache.cs | 71 + .../Utilities/SchedulePreviewCalculator.cs | 245 + src/Werkr.Server/Werkr.Server.csproj | 24 + src/Werkr.Server/appsettings.Development.json | 17 + src/Werkr.Server/appsettings.json | 29 + src/Werkr.Server/packages.lock.json | 512 + src/Werkr.Server/wwwroot/app.css | 96 + src/Werkr.Server/wwwroot/css/theme.css | 296 + src/Werkr.Server/wwwroot/favicon.png | Bin 0 -> 1148 bytes .../wwwroot/fonts/CascadiaMonoNF.woff2 | Bin 0 -> 977568 bytes src/Werkr.Server/wwwroot/js/toggle-theme.js | 43 + .../lib/bootstrap/dist/css/bootstrap-grid.css | 4085 ++++++ .../bootstrap/dist/css/bootstrap-grid.css.map | 1 + .../bootstrap/dist/css/bootstrap-grid.min.css | 6 + .../dist/css/bootstrap-grid.min.css.map | 1 + .../bootstrap/dist/css/bootstrap-grid.rtl.css | 4084 ++++++ .../dist/css/bootstrap-grid.rtl.css.map | 1 + .../dist/css/bootstrap-grid.rtl.min.css | 6 + .../dist/css/bootstrap-grid.rtl.min.css.map | 1 + .../bootstrap/dist/css/bootstrap-reboot.css | 597 + .../dist/css/bootstrap-reboot.css.map | 1 + .../dist/css/bootstrap-reboot.min.css | 6 + .../dist/css/bootstrap-reboot.min.css.map | 1 + .../dist/css/bootstrap-reboot.rtl.css | 594 + .../dist/css/bootstrap-reboot.rtl.css.map | 1 + .../dist/css/bootstrap-reboot.rtl.min.css | 6 + .../dist/css/bootstrap-reboot.rtl.min.css.map | 1 + .../dist/css/bootstrap-utilities.css | 5402 +++++++ .../dist/css/bootstrap-utilities.css.map | 1 + .../dist/css/bootstrap-utilities.min.css | 6 + .../dist/css/bootstrap-utilities.min.css.map | 1 + .../dist/css/bootstrap-utilities.rtl.css | 5393 +++++++ .../dist/css/bootstrap-utilities.rtl.css.map | 1 + .../dist/css/bootstrap-utilities.rtl.min.css | 6 + .../css/bootstrap-utilities.rtl.min.css.map | 1 + .../lib/bootstrap/dist/css/bootstrap.css | 12057 ++++++++++++++++ .../lib/bootstrap/dist/css/bootstrap.css.map | 1 + .../lib/bootstrap/dist/css/bootstrap.min.css | 6 + .../bootstrap/dist/css/bootstrap.min.css.map | 1 + .../lib/bootstrap/dist/css/bootstrap.rtl.css | 12030 +++++++++++++++ .../bootstrap/dist/css/bootstrap.rtl.css.map | 1 + .../bootstrap/dist/css/bootstrap.rtl.min.css | 6 + .../dist/css/bootstrap.rtl.min.css.map | 1 + .../lib/bootstrap/dist/js/bootstrap.bundle.js | 6314 ++++++++ .../bootstrap/dist/js/bootstrap.bundle.js.map | 1 + .../bootstrap/dist/js/bootstrap.bundle.min.js | 7 + .../dist/js/bootstrap.bundle.min.js.map | 1 + .../lib/bootstrap/dist/js/bootstrap.esm.js | 4447 ++++++ .../bootstrap/dist/js/bootstrap.esm.js.map | 1 + .../bootstrap/dist/js/bootstrap.esm.min.js | 7 + .../dist/js/bootstrap.esm.min.js.map | 1 + .../lib/bootstrap/dist/js/bootstrap.js | 4494 ++++++ .../lib/bootstrap/dist/js/bootstrap.js.map | 1 + .../lib/bootstrap/dist/js/bootstrap.min.js | 7 + .../bootstrap/dist/js/bootstrap.min.js.map | 1 + src/Werkr.ServiceDefaults/Extensions.cs | 115 + .../Werkr.ServiceDefaults.csproj | 17 + src/Werkr.ServiceDefaults/packages.lock.json | 148 + 610 files changed, 128151 insertions(+), 3758 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 GitVersion.yml create mode 100644 Werkr.slnx create mode 100644 docker-compose.yml create mode 100644 docs/images/WerkrAgentMsiDialog.bmp create mode 100644 docs/images/WerkrLogo.ico create mode 100644 docs/images/WerkrLogoWithText_GithubBanner.png create mode 100644 docs/images/WerkrMsiBanner.bmp create mode 100644 docs/images/WerkrServerMsiDialog.bmp create mode 100644 global.json create mode 100644 scripts/docker-build.ps1 create mode 100644 scripts/docker-build.sh create mode 100644 scripts/publish.ps1 create mode 100644 src/Installer/Msi/Agent/Agent.wixproj create mode 100644 src/Installer/Msi/Agent/Agent.wxs create mode 100644 src/Installer/Msi/Agent/Package.en-us.wxl create mode 100644 src/Installer/Msi/Agent/images/WerkrAgentMsiDialog.bmp create mode 100644 src/Installer/Msi/Agent/images/WerkrMsiBanner.bmp create mode 100644 src/Installer/Msi/Agent/packages.lock.json create mode 100644 src/Installer/Msi/CustomActions/CustomAction.config create mode 100644 src/Installer/Msi/CustomActions/CustomAction.cs create mode 100644 src/Installer/Msi/CustomActions/Werkr.Installer.Msi.CustomActions.csproj create mode 100644 src/Installer/Msi/CustomActions/packages.lock.json create mode 100644 src/Installer/Msi/Server/Package.en-us.wxl create mode 100644 src/Installer/Msi/Server/Server.wixproj create mode 100644 src/Installer/Msi/Server/Server.wxs create mode 100644 src/Installer/Msi/Server/images/WerkrMsiBanner.bmp create mode 100644 src/Installer/Msi/Server/images/WerkrServerMsiDialog.bmp create mode 100644 src/Installer/Msi/Server/packages.lock.json create mode 100644 src/Test/Werkr.Tests.Agent/Helpers/AllowAllPathValidator.cs create mode 100644 src/Test/Werkr.Tests.Agent/Helpers/AllowPrefixValidator.cs create mode 100644 src/Test/Werkr.Tests.Agent/Helpers/DenyAllPathValidator.cs create mode 100644 src/Test/Werkr.Tests.Agent/Helpers/FailHandler.cs create mode 100644 src/Test/Werkr.Tests.Agent/Helpers/MockServerStreamWriter.cs create mode 100644 src/Test/Werkr.Tests.Agent/Helpers/SlowHandler.cs create mode 100644 src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs create mode 100644 src/Test/Werkr.Tests.Agent/Helpers/TestActionDescriptor.cs create mode 100644 src/Test/Werkr.Tests.Agent/Helpers/TestFilePathResolver.cs create mode 100644 src/Test/Werkr.Tests.Agent/Helpers/TestServerCallContext.cs create mode 100644 src/Test/Werkr.Tests.Agent/Helpers/ThrowHandler.cs create mode 100644 src/Test/Werkr.Tests.Agent/Interceptors/BearerTokenInterceptorTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/ActionOperatorTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/Actions/ClearContentHandlerTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/Actions/CopyFileHandlerTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/Actions/CreateDirectoryHandlerTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/Actions/CreateFileHandlerTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/Actions/DeleteFileHandlerTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/Actions/MoveFileHandlerTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/Actions/RenameFileHandlerTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/Actions/StartProcessHandlerTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/Actions/StopProcessHandlerTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/Actions/TestExistsHandlerTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/Actions/WriteContentHandlerTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/PSHostTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/PwshOperatorTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Operators/SystemShellOperatorTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Scheduling/ScheduleEvaluatorServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Security/FilePathResolverTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Security/PathAllowlistValidatorTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Services/OperatorOutputAdapterTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Services/PwshServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Services/SystemShellServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Agent/Werkr.Tests.Agent.csproj create mode 100644 src/Test/Werkr.Tests.Agent/packages.lock.json create mode 100644 src/Test/Werkr.Tests.Data/Unit/Collections/LoopingListTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Communication/AgentConnectionManagerTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Communication/CommandDispatcherTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Communication/KeyRotationTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Communication/NullEncryptionTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Communication/PayloadEncryptorTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Configuration/ConfigurationExtensionsTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Cryptography/EncryptionProviderTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Cryptography/HybridEncryptionTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Cryptography/PlatformValidationTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Ranges/IntRangeTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfDaysTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfMonthsTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfWeekNumsTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationBundleGeneratorTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationBundlePayloadTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Scheduling/CalendarEnumExtensionsTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayCalculatorTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayCalendarServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayDateServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayFilterTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayRuleValidatorTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleCalculatorTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleDescriptionBuilderTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Security/SecretStoreTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Tasks/AgentResolverTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Tasks/SuccessCriteriaEvaluatorTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Tasks/TaskServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Workflows/ConditionEvaluatorTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowExecutorTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Data/Werkr.Tests.Data.csproj create mode 100644 src/Test/Werkr.Tests.Data/packages.lock.json create mode 100644 src/Test/Werkr.Tests.Server/ActionParameterRegistryTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Authorization/AuthorizationAttributeTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Authorization/PageAuthorizationTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Identity/ApiKeyServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Identity/IdentityFlowTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Identity/IdentityOptionsTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Identity/IdentitySeederTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Identity/JwtTokenServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Identity/PermissionServiceTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Identity/SecurityTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Identity/UrlValidatorTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Identity/UserManagementTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Identity/WerkrCookieAuthEventsTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Pages/AgentDetailTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Pages/DashboardTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Pages/DtoModelTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Pages/PageDiscoveryTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Pages/SettingsTests.cs create mode 100644 src/Test/Werkr.Tests.Server/Placeholder.cs create mode 100644 src/Test/Werkr.Tests.Server/Werkr.Tests.Server.csproj create mode 100644 src/Test/Werkr.Tests.Server/packages.lock.json create mode 100644 src/Test/Werkr.Tests/AppHostFixture.cs create mode 100644 src/Test/Werkr.Tests/Integration/ActionDispatchIntegrationTests.cs create mode 100644 src/Test/Werkr.Tests/Integration/HolidayCalendarIntegrationTests.cs create mode 100644 src/Test/Werkr.Tests/Integration/ScheduleExecutionTests.cs create mode 100644 src/Test/Werkr.Tests/Integration/ScheduledActionTests.cs create mode 100644 src/Test/Werkr.Tests/Integration/WorkflowIntegrationTests.cs create mode 100644 src/Test/Werkr.Tests/ScheduleIntegrationTests.cs create mode 100644 src/Test/Werkr.Tests/WebTests.cs create mode 100644 src/Test/Werkr.Tests/Werkr.Tests.csproj create mode 100644 src/Test/Werkr.Tests/Werkr.Tests.runsettings create mode 100644 src/Test/Werkr.Tests/packages.lock.json create mode 100644 src/Werkr.Agent/Communication/AgentGrpcClientFactory.cs create mode 100644 src/Werkr.Agent/Dockerfile create mode 100644 src/Werkr.Agent/Interceptors/BearerTokenInterceptor.cs create mode 100644 src/Werkr.Agent/Operators/ActionHandlerServiceCollectionExtensions.cs create mode 100644 src/Werkr.Agent/Operators/ActionOperator.cs create mode 100644 src/Werkr.Agent/Operators/Actions/ActionJson.cs create mode 100644 src/Werkr.Agent/Operators/Actions/ClearContentHandler.cs create mode 100644 src/Werkr.Agent/Operators/Actions/CopyFileHandler.cs create mode 100644 src/Werkr.Agent/Operators/Actions/CreateDirectoryHandler.cs create mode 100644 src/Werkr.Agent/Operators/Actions/CreateFileHandler.cs create mode 100644 src/Werkr.Agent/Operators/Actions/DeleteFileHandler.cs create mode 100644 src/Werkr.Agent/Operators/Actions/MoveFileHandler.cs create mode 100644 src/Werkr.Agent/Operators/Actions/RenameFileHandler.cs create mode 100644 src/Werkr.Agent/Operators/Actions/StartProcessHandler.cs create mode 100644 src/Werkr.Agent/Operators/Actions/StopProcessHandler.cs create mode 100644 src/Werkr.Agent/Operators/Actions/TestExistsHandler.cs create mode 100644 src/Werkr.Agent/Operators/Actions/WriteContentHandler.cs create mode 100644 src/Werkr.Agent/Operators/PwshOperator.cs create mode 100644 src/Werkr.Agent/Operators/SystemShellOperator.cs create mode 100644 src/Werkr.Agent/Operators/WerkrPSHost.cs create mode 100644 src/Werkr.Agent/Operators/WerkrPSHostRawUserInterface.cs create mode 100644 src/Werkr.Agent/Operators/WerkrPSHostUserInterface.cs create mode 100644 src/Werkr.Agent/Program.cs create mode 100644 src/Werkr.Agent/Protos/Action.proto create mode 100644 src/Werkr.Agent/Protos/GrpcLogMsg.proto create mode 100644 src/Werkr.Agent/Protos/Shell.proto create mode 100644 src/Werkr.Agent/Registration/AgentRegistrationHandler.cs create mode 100644 src/Werkr.Agent/Registration/RegistrationEndpoints.cs create mode 100644 src/Werkr.Agent/Scheduling/AgentJobOutputWriter.cs create mode 100644 src/Werkr.Agent/Scheduling/ScheduleEvaluatorService.cs create mode 100644 src/Werkr.Agent/Security/FilePathResolver.cs create mode 100644 src/Werkr.Agent/Security/NativeMethods.cs create mode 100644 src/Werkr.Agent/Security/PathAllowlistValidator.cs create mode 100644 src/Werkr.Agent/Services/ActionService.cs create mode 100644 src/Werkr.Agent/Services/ConnectionManagementService.cs create mode 100644 src/Werkr.Agent/Services/OperatorOutputAdapter.cs create mode 100644 src/Werkr.Agent/Services/OutputFetchService.cs create mode 100644 src/Werkr.Agent/Services/PwshService.cs create mode 100644 src/Werkr.Agent/Services/ScheduleInvalidationService.cs create mode 100644 src/Werkr.Agent/Services/SystemShellService.cs create mode 100644 src/Werkr.Agent/Werkr.Agent.csproj create mode 100644 src/Werkr.Agent/appsettings.Development.json create mode 100644 src/Werkr.Agent/appsettings.json create mode 100644 src/Werkr.Agent/packages.lock.json create mode 100644 src/Werkr.Api/Authorization/ClaimsPermissionAuthorizationHandler.cs create mode 100644 src/Werkr.Api/Dockerfile create mode 100644 src/Werkr.Api/Endpoints/AgentEndpoints.cs create mode 100644 src/Werkr.Api/Endpoints/AuthProxyEndpoints.cs create mode 100644 src/Werkr.Api/Endpoints/DiagnosticsEndpoints.cs create mode 100644 src/Werkr.Api/Endpoints/HolidayCalendarEndpoints.cs create mode 100644 src/Werkr.Api/Endpoints/JobEndpoints.cs create mode 100644 src/Werkr.Api/Endpoints/RegistrationEndpoints.cs create mode 100644 src/Werkr.Api/Endpoints/ScheduleEndpoints.cs create mode 100644 src/Werkr.Api/Endpoints/SettingsEndpoints.cs create mode 100644 src/Werkr.Api/Endpoints/ShellEndpoints.cs create mode 100644 src/Werkr.Api/Endpoints/StatusEndpoints.cs create mode 100644 src/Werkr.Api/Endpoints/TaskEndpoints.cs create mode 100644 src/Werkr.Api/Endpoints/WorkflowEndpoints.cs create mode 100644 src/Werkr.Api/Interceptors/AgentBearerTokenInterceptor.cs create mode 100644 src/Werkr.Api/Models/HolidayCalendarMapper.cs create mode 100644 src/Werkr.Api/Models/ScheduleMapper.cs create mode 100644 src/Werkr.Api/Models/TaskMapper.cs create mode 100644 src/Werkr.Api/Models/WorkflowMapper.cs create mode 100644 src/Werkr.Api/Program.cs create mode 100644 src/Werkr.Api/Protos/Registration.proto create mode 100644 src/Werkr.Api/Services/AuditLogCleanupService.cs create mode 100644 src/Werkr.Api/Services/AuditLogOptions.cs create mode 100644 src/Werkr.Api/Services/HolidayCalendarService.cs create mode 100644 src/Werkr.Api/Services/JobReportingGrpcService.cs create mode 100644 src/Werkr.Api/Services/RegistrationGrpcService.cs create mode 100644 src/Werkr.Api/Services/ScheduleInvalidationDispatcher.cs create mode 100644 src/Werkr.Api/Services/ScheduleSyncGrpcService.cs create mode 100644 src/Werkr.Api/Services/WorkflowExecutionGrpcService.cs create mode 100644 src/Werkr.Api/Werkr.Api.csproj create mode 100644 src/Werkr.Api/Werkr.Api.http create mode 100644 src/Werkr.Api/appsettings.Development.json create mode 100644 src/Werkr.Api/appsettings.json create mode 100644 src/Werkr.Api/packages.lock.json create mode 100644 src/Werkr.AppHost/AppHost.cs create mode 100644 src/Werkr.AppHost/Werkr.AppHost.csproj create mode 100644 src/Werkr.AppHost/appsettings.Development.json create mode 100644 src/Werkr.AppHost/appsettings.json create mode 100644 src/Werkr.AppHost/packages.lock.json create mode 100644 src/Werkr.Common.Configuration/AgentSettings.cs create mode 100644 src/Werkr.Common.Configuration/JobOutputOptions.cs create mode 100644 src/Werkr.Common.Configuration/PowerShellSettings.cs create mode 100644 src/Werkr.Common.Configuration/ServerSettings.cs create mode 100644 src/Werkr.Common.Configuration/UiSettings.cs create mode 100644 src/Werkr.Common.Configuration/Werkr.Common.Configuration.csproj create mode 100644 src/Werkr.Common.Configuration/WerkrConfiguration.cs create mode 100644 src/Werkr.Common.Configuration/packages.lock.json create mode 100644 src/Werkr.Common/Auth/JwtValidationConfigurator.cs create mode 100644 src/Werkr.Common/Auth/Permission.cs create mode 100644 src/Werkr.Common/Auth/PermissionPolicyExtensions.cs create mode 100644 src/Werkr.Common/Auth/PermissionRequirement.cs create mode 100644 src/Werkr.Common/Auth/Policies.cs create mode 100644 src/Werkr.Common/Auth/WerkrClaimTypes.cs create mode 100644 src/Werkr.Common/Configuration/Registry/RegistryConfigurationExtensions.cs create mode 100644 src/Werkr.Common/Configuration/Registry/RegistryConfigurationProvider.cs create mode 100644 src/Werkr.Common/Configuration/Registry/RegistryConfigurationSource.cs create mode 100644 src/Werkr.Common/DatabaseProvider.cs create mode 100644 src/Werkr.Common/Extensions/ConfigurationBuilderExtensions.cs create mode 100644 src/Werkr.Common/Models/ActionOperatorConfiguration.cs create mode 100644 src/Werkr.Common/Models/Actions/ActionDescriptor.cs create mode 100644 src/Werkr.Common/Models/Actions/ClearContentParameters.cs create mode 100644 src/Werkr.Common/Models/Actions/CopyFileParameters.cs create mode 100644 src/Werkr.Common/Models/Actions/CreateDirectoryParameters.cs create mode 100644 src/Werkr.Common/Models/Actions/CreateFileParameters.cs create mode 100644 src/Werkr.Common/Models/Actions/DeleteFileParameters.cs create mode 100644 src/Werkr.Common/Models/Actions/MoveFileParameters.cs create mode 100644 src/Werkr.Common/Models/Actions/RenameFileParameters.cs create mode 100644 src/Werkr.Common/Models/Actions/StartProcessParameters.cs create mode 100644 src/Werkr.Common/Models/Actions/StopProcessParameters.cs create mode 100644 src/Werkr.Common/Models/Actions/TestExistsParameters.cs create mode 100644 src/Werkr.Common/Models/Actions/WriteContentParameters.cs create mode 100644 src/Werkr.Common/Models/AgentActivityDto.cs create mode 100644 src/Werkr.Common/Models/AgentConnectionDto.cs create mode 100644 src/Werkr.Common/Models/AgentDetailDto.cs create mode 100644 src/Werkr.Common/Models/AgentHealthDto.cs create mode 100644 src/Werkr.Common/Models/AgentListDto.cs create mode 100644 src/Werkr.Common/Models/AgentStatus.cs create mode 100644 src/Werkr.Common/Models/AllowedPathsConfiguration.cs create mode 100644 src/Werkr.Common/Models/ApiKeyCreateRequest.cs create mode 100644 src/Werkr.Common/Models/ApiKeyCreateResponse.cs create mode 100644 src/Werkr.Common/Models/ApiKeyDto.cs create mode 100644 src/Werkr.Common/Models/ConnectionStatus.cs create mode 100644 src/Werkr.Common/Models/DagValidationResult.cs create mode 100644 src/Werkr.Common/Models/DailyRecurrenceDto.cs create mode 100644 src/Werkr.Common/Models/DatabaseHealthDto.cs create mode 100644 src/Werkr.Common/Models/ExecuteCommandRequest.cs create mode 100644 src/Werkr.Common/Models/ExecuteCommandResponse.cs create mode 100644 src/Werkr.Common/Models/ExpirationDateTimeDto.cs create mode 100644 src/Werkr.Common/Models/Holidays/AttachHolidayCalendarRequest.cs create mode 100644 src/Werkr.Common/Models/Holidays/BulkHolidayDateCreateRequest.cs create mode 100644 src/Werkr.Common/Models/Holidays/BulkScheduleHolidayDatesResponse.cs create mode 100644 src/Werkr.Common/Models/Holidays/CloneHolidayCalendarRequest.cs create mode 100644 src/Werkr.Common/Models/Holidays/HolidayCalendarCreateRequest.cs create mode 100644 src/Werkr.Common/Models/Holidays/HolidayCalendarDto.cs create mode 100644 src/Werkr.Common/Models/Holidays/HolidayCalendarSummaryDto.cs create mode 100644 src/Werkr.Common/Models/Holidays/HolidayCalendarUpdateRequest.cs create mode 100644 src/Werkr.Common/Models/Holidays/HolidayDateCreateRequest.cs create mode 100644 src/Werkr.Common/Models/Holidays/HolidayDateDto.cs create mode 100644 src/Werkr.Common/Models/Holidays/HolidayPreviewResponse.cs create mode 100644 src/Werkr.Common/Models/Holidays/HolidayRuleCreateRequest.cs create mode 100644 src/Werkr.Common/Models/Holidays/HolidayRuleDto.cs create mode 100644 src/Werkr.Common/Models/Holidays/HolidayRuleUpdateRequest.cs create mode 100644 src/Werkr.Common/Models/Holidays/RulePreviewRequest.cs create mode 100644 src/Werkr.Common/Models/Holidays/RulePreviewResponse.cs create mode 100644 src/Werkr.Common/Models/Holidays/ScheduleAuditLogCreateRequest.cs create mode 100644 src/Werkr.Common/Models/Holidays/ScheduleAuditLogDto.cs create mode 100644 src/Werkr.Common/Models/Holidays/ScheduleHolidayCalendarDto.cs create mode 100644 src/Werkr.Common/Models/Holidays/ScheduleHolidayDatesResponse.cs create mode 100644 src/Werkr.Common/Models/Holidays/SuppressedOccurrenceDto.cs create mode 100644 src/Werkr.Common/Models/JobDto.cs create mode 100644 src/Werkr.Common/Models/JobListDto.cs create mode 100644 src/Werkr.Common/Models/MonthlyRecurrenceDto.cs create mode 100644 src/Werkr.Common/Models/NotifyUrlChangeRequest.cs create mode 100644 src/Werkr.Common/Models/NotifyUrlChangeResponse.cs create mode 100644 src/Werkr.Common/Models/OccurrencePreviewResponse.cs create mode 100644 src/Werkr.Common/Models/OperatorOutputLine.cs create mode 100644 src/Werkr.Common/Models/OperatorType.cs create mode 100644 src/Werkr.Common/Models/PathType.cs create mode 100644 src/Werkr.Common/Models/RegistrationGenerateRequest.cs create mode 100644 src/Werkr.Common/Models/RegistrationGenerateResponse.cs create mode 100644 src/Werkr.Common/Models/RegistrationStatus.cs create mode 100644 src/Werkr.Common/Models/RepeatOptionsDto.cs create mode 100644 src/Werkr.Common/Models/ScheduleCreateRequest.cs create mode 100644 src/Werkr.Common/Models/ScheduleDto.cs create mode 100644 src/Werkr.Common/Models/ScheduleUpdateRequest.cs create mode 100644 src/Werkr.Common/Models/StartDateTimeDto.cs create mode 100644 src/Werkr.Common/Models/StepDependencyDto.cs create mode 100644 src/Werkr.Common/Models/StepDependencyRequest.cs create mode 100644 src/Werkr.Common/Models/TaskCreateRequest.cs create mode 100644 src/Werkr.Common/Models/TaskDto.cs create mode 100644 src/Werkr.Common/Models/TaskRunRequest.cs create mode 100644 src/Werkr.Common/Models/TaskSetEnabledRequest.cs create mode 100644 src/Werkr.Common/Models/TaskUpdateRequest.cs create mode 100644 src/Werkr.Common/Models/TokenRequest.cs create mode 100644 src/Werkr.Common/Models/TokenResponse.cs create mode 100644 src/Werkr.Common/Models/UpdateAgentRequest.cs create mode 100644 src/Werkr.Common/Models/UpdateAgentStatusRequest.cs create mode 100644 src/Werkr.Common/Models/UpdateAgentTagsRequest.cs create mode 100644 src/Werkr.Common/Models/WeeklyRecurrenceDto.cs create mode 100644 src/Werkr.Common/Models/WorkflowCreateRequest.cs create mode 100644 src/Werkr.Common/Models/WorkflowDto.cs create mode 100644 src/Werkr.Common/Models/WorkflowRunDetailDto.cs create mode 100644 src/Werkr.Common/Models/WorkflowRunDto.cs create mode 100644 src/Werkr.Common/Models/WorkflowRunRequest.cs create mode 100644 src/Werkr.Common/Models/WorkflowSetEnabledRequest.cs create mode 100644 src/Werkr.Common/Models/WorkflowStepCreateRequest.cs create mode 100644 src/Werkr.Common/Models/WorkflowStepDto.cs create mode 100644 src/Werkr.Common/Models/WorkflowStepStatusUpdate.cs create mode 100644 src/Werkr.Common/Models/WorkflowStepUpdateRequest.cs create mode 100644 src/Werkr.Common/Models/WorkflowUpdateRequest.cs create mode 100644 src/Werkr.Common/Protos/ConnectionManagement.proto create mode 100644 src/Werkr.Common/Protos/EncryptedEnvelope.proto create mode 100644 src/Werkr.Common/Protos/JobReport.proto create mode 100644 src/Werkr.Common/Protos/OutputFetch.proto create mode 100644 src/Werkr.Common/Protos/ScheduleInvalidation.proto create mode 100644 src/Werkr.Common/Protos/ScheduleSync.proto create mode 100644 src/Werkr.Common/Protos/WorkflowExecution.proto create mode 100644 src/Werkr.Common/Rendering/AnsiHtmlConverter.cs create mode 100644 src/Werkr.Common/Werkr.Common.csproj create mode 100644 src/Werkr.Common/packages.lock.json create mode 100644 src/Werkr.Core/Communication/AgentConnectionManager.cs create mode 100644 src/Werkr.Core/Communication/CommandDispatchFailure.cs create mode 100644 src/Werkr.Core/Communication/CommandDispatcher.cs create mode 100644 src/Werkr.Core/Communication/CommandDispatcherException.cs create mode 100644 src/Werkr.Core/Communication/GrpcOutputReader.cs create mode 100644 src/Werkr.Core/Communication/ICommandDispatcher.cs create mode 100644 src/Werkr.Core/Communication/KeyRotationService.cs create mode 100644 src/Werkr.Core/Communication/OperatorOutput.cs create mode 100644 src/Werkr.Core/Communication/PayloadEncryptor.cs create mode 100644 src/Werkr.Core/Cryptography/EncryptionProvider.cs create mode 100644 src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionData.cs create mode 100644 src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionNote.cs create mode 100644 src/Werkr.Core/Cryptography/KeyInfo/RSAKeyPair.cs create mode 100644 src/Werkr.Core/Cryptography/WerkrCryptoException.cs create mode 100644 src/Werkr.Core/Health/AgentHealthCheckService.cs create mode 100644 src/Werkr.Core/Operators/ActionOperatorResult.cs create mode 100644 src/Werkr.Core/Operators/IActionHandler.cs create mode 100644 src/Werkr.Core/Operators/IActionOperator.cs create mode 100644 src/Werkr.Core/Operators/IOperatorResult.cs create mode 100644 src/Werkr.Core/Operators/IShellOperator.cs create mode 100644 src/Werkr.Core/Operators/OperatorExecution.cs create mode 100644 src/Werkr.Core/Operators/PwshOperatorResult.cs create mode 100644 src/Werkr.Core/Operators/ShellOperatorResult.cs create mode 100644 src/Werkr.Core/Registration/BundleExpirationService.cs create mode 100644 src/Werkr.Core/Registration/Models/AgentRegistrationResult.cs create mode 100644 src/Werkr.Core/Registration/Models/RegistrationBundlePayload.cs create mode 100644 src/Werkr.Core/Registration/Models/RegistrationResponsePayload.cs create mode 100644 src/Werkr.Core/Registration/RegistrationBundleGenerator.cs create mode 100644 src/Werkr.Core/Registration/RegistrationService.cs create mode 100644 src/Werkr.Core/Scheduling/HolidayCalculator.cs create mode 100644 src/Werkr.Core/Scheduling/HolidayDateService.cs create mode 100644 src/Werkr.Core/Scheduling/ScheduleCalculator.cs create mode 100644 src/Werkr.Core/Scheduling/ScheduleDescriptionBuilder.cs create mode 100644 src/Werkr.Core/Scheduling/ScheduleOccurrenceResult.cs create mode 100644 src/Werkr.Core/Scheduling/ScheduleService.cs create mode 100644 src/Werkr.Core/Scheduling/SuppressedOccurrence.cs create mode 100644 src/Werkr.Core/Security/IFilePathResolver.cs create mode 100644 src/Werkr.Core/Security/IPathAllowlistValidator.cs create mode 100644 src/Werkr.Core/Security/ISecretStore.cs create mode 100644 src/Werkr.Core/Security/LinuxSecretStore.cs create mode 100644 src/Werkr.Core/Security/MacOsSecretStore.cs create mode 100644 src/Werkr.Core/Security/SecretStoreFactory.cs create mode 100644 src/Werkr.Core/Security/WindowsSecretStore.cs create mode 100644 src/Werkr.Core/Tasks/AgentResolver.cs create mode 100644 src/Werkr.Core/Tasks/JobExecutionService.cs create mode 100644 src/Werkr.Core/Tasks/JobOutputWriter.cs create mode 100644 src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs create mode 100644 src/Werkr.Core/Tasks/TaskService.cs create mode 100644 src/Werkr.Core/Werkr.Core.csproj create mode 100644 src/Werkr.Core/Workflows/ConditionEvaluator.cs create mode 100644 src/Werkr.Core/Workflows/WorkflowExecutor.cs create mode 100644 src/Werkr.Core/Workflows/WorkflowRunTracker.cs create mode 100644 src/Werkr.Core/Workflows/WorkflowService.cs create mode 100644 src/Werkr.Core/packages.lock.json create mode 100644 src/Werkr.Data.Identity/Authorization/PermissionAuthorizationHandler.cs create mode 100644 src/Werkr.Data.Identity/Entities/ApiKey.cs create mode 100644 src/Werkr.Data.Identity/Entities/ConfigurationSettings.cs create mode 100644 src/Werkr.Data.Identity/Entities/RolePermission.cs create mode 100644 src/Werkr.Data.Identity/Entities/WerkrUser.cs create mode 100644 src/Werkr.Data.Identity/Extensions/IdentityExtensions.cs create mode 100644 src/Werkr.Data.Identity/PostgresIdentityDesignTimeFactory.cs create mode 100644 src/Werkr.Data.Identity/PostgresWerkrIdentityDbContext.cs create mode 100644 src/Werkr.Data.Identity/Roles/DefaultRoles.cs create mode 100644 src/Werkr.Data.Identity/Services/IPermissionService.cs create mode 100644 src/Werkr.Data.Identity/Services/PermissionService.cs create mode 100644 src/Werkr.Data.Identity/SqliteIdentityDesignTimeFactory.cs create mode 100644 src/Werkr.Data.Identity/SqliteWerkrIdentityDbContext.cs create mode 100644 src/Werkr.Data.Identity/Werkr.Data.Identity.csproj create mode 100644 src/Werkr.Data.Identity/WerkrIdentityDbContext.cs create mode 100644 src/Werkr.Data.Identity/packages.lock.json create mode 100644 src/Werkr.Data/Calendar/Enums/DaysOfWeek.cs create mode 100644 src/Werkr.Data/Calendar/Enums/HolidayCalendarMode.cs create mode 100644 src/Werkr.Data/Calendar/Enums/HolidayRuleType.cs create mode 100644 src/Werkr.Data/Calendar/Enums/Month.cs create mode 100644 src/Werkr.Data/Calendar/Enums/MonthsOfYear.cs create mode 100644 src/Werkr.Data/Calendar/Enums/ObservanceRule.cs create mode 100644 src/Werkr.Data/Calendar/Enums/WeekNumberWithinMonth.cs create mode 100644 src/Werkr.Data/Calendar/Extensions/CalendarEnumExtensions.cs create mode 100644 src/Werkr.Data/Calendar/Models/Schedule.cs create mode 100644 src/Werkr.Data/Calendar/Validation/HolidayRuleValidator.cs create mode 100644 src/Werkr.Data/Calendar/Validation/MonthlyRecurrenceValidator.cs create mode 100644 src/Werkr.Data/Calendar/Validation/ScheduleValidator.cs create mode 100644 src/Werkr.Data/Calendar/Validation/WerkrTaskValidator.cs create mode 100644 src/Werkr.Data/Collections/LoopingList.cs create mode 100644 src/Werkr.Data/Encryption/EncryptedByteArrayConverter.cs create mode 100644 src/Werkr.Data/Encryption/EncryptedStringConverter.cs create mode 100644 src/Werkr.Data/Encryption/FieldEncryptionProvider.cs create mode 100644 src/Werkr.Data/Entities/ConcurrencyBase.cs create mode 100644 src/Werkr.Data/Entities/Interfaces/IKey.cs create mode 100644 src/Werkr.Data/Entities/Registration/RegisteredConnection.cs create mode 100644 src/Werkr.Data/Entities/Registration/RegistrationBundle.cs create mode 100644 src/Werkr.Data/Entities/Schedule/DailyRecurrence.cs create mode 100644 src/Werkr.Data/Entities/Schedule/DateTimeInfoBase.cs create mode 100644 src/Werkr.Data/Entities/Schedule/DbSchedule.cs create mode 100644 src/Werkr.Data/Entities/Schedule/ExpirationDateTimeInfo.cs create mode 100644 src/Werkr.Data/Entities/Schedule/HolidayCalendar.cs create mode 100644 src/Werkr.Data/Entities/Schedule/HolidayDate.cs create mode 100644 src/Werkr.Data/Entities/Schedule/HolidayRule.cs create mode 100644 src/Werkr.Data/Entities/Schedule/MonthlyRecurrence.cs create mode 100644 src/Werkr.Data/Entities/Schedule/ScheduleAuditLog.cs create mode 100644 src/Werkr.Data/Entities/Schedule/ScheduleHolidayCalendar.cs create mode 100644 src/Werkr.Data/Entities/Schedule/ScheduleRepeatOptions.cs create mode 100644 src/Werkr.Data/Entities/Schedule/StartDateTimeInfo.cs create mode 100644 src/Werkr.Data/Entities/Schedule/WeeklyRecurrence.cs create mode 100644 src/Werkr.Data/Entities/Settings/ConfigurationSettings.cs create mode 100644 src/Werkr.Data/Entities/Tasks/ErrorCategory.cs create mode 100644 src/Werkr.Data/Entities/Tasks/TaskActionType.cs create mode 100644 src/Werkr.Data/Entities/Tasks/WerkrJob.cs create mode 100644 src/Werkr.Data/Entities/Tasks/WerkrTask.cs create mode 100644 src/Werkr.Data/Entities/Workflows/ControlStatement.cs create mode 100644 src/Werkr.Data/Entities/Workflows/DependencyMode.cs create mode 100644 src/Werkr.Data/Entities/Workflows/Workflow.cs create mode 100644 src/Werkr.Data/Entities/Workflows/WorkflowRun.cs create mode 100644 src/Werkr.Data/Entities/Workflows/WorkflowRunStatus.cs create mode 100644 src/Werkr.Data/Entities/Workflows/WorkflowStep.cs create mode 100644 src/Werkr.Data/Entities/Workflows/WorkflowStepDependency.cs create mode 100644 src/Werkr.Data/PostgresDesignTimeFactory.cs create mode 100644 src/Werkr.Data/PostgresWerkrDbContext.cs create mode 100644 src/Werkr.Data/Ranges/IRange.cs create mode 100644 src/Werkr.Data/Ranges/IntRange.cs create mode 100644 src/Werkr.Data/Ranges/RangeOfDays.cs create mode 100644 src/Werkr.Data/Ranges/RangeOfMonths.cs create mode 100644 src/Werkr.Data/Ranges/RangeOfWeekNums.cs create mode 100644 src/Werkr.Data/Seeding/HolidayCalendarSeeder.cs create mode 100644 src/Werkr.Data/SqliteDesignTimeFactory.cs create mode 100644 src/Werkr.Data/SqliteWerkrDbContext.cs create mode 100644 src/Werkr.Data/Werkr.Data.csproj create mode 100644 src/Werkr.Data/WerkrDbContext.cs create mode 100644 src/Werkr.Data/WerkrDbContextExtensions.cs create mode 100644 src/Werkr.Data/packages.lock.json create mode 100644 src/Werkr.Server/Components/App.razor create mode 100644 src/Werkr.Server/Components/Layout/MainLayout.razor create mode 100644 src/Werkr.Server/Components/Layout/MainLayout.razor.css create mode 100644 src/Werkr.Server/Components/Layout/NavMenu.razor create mode 100644 src/Werkr.Server/Components/Layout/NavMenu.razor.css create mode 100644 src/Werkr.Server/Components/Pages/Account/AccessDenied.razor create mode 100644 src/Werkr.Server/Components/Pages/Account/ChangePassword.razor create mode 100644 src/Werkr.Server/Components/Pages/Account/Login.razor create mode 100644 src/Werkr.Server/Components/Pages/Account/Logout.razor create mode 100644 src/Werkr.Server/Components/Pages/Account/Manage/Index.razor create mode 100644 src/Werkr.Server/Components/Pages/Account/Manage/Mfa.razor create mode 100644 src/Werkr.Server/Components/Pages/Account/MfaRecovery.razor create mode 100644 src/Werkr.Server/Components/Pages/Account/MfaVerify.razor create mode 100644 src/Werkr.Server/Components/Pages/Admin/CreateUser.razor create mode 100644 src/Werkr.Server/Components/Pages/Admin/EditUser.razor create mode 100644 src/Werkr.Server/Components/Pages/Admin/Users.razor create mode 100644 src/Werkr.Server/Components/Pages/AgentDetail.razor create mode 100644 src/Werkr.Server/Components/Pages/Agents.razor create mode 100644 src/Werkr.Server/Components/Pages/Agents/Console.razor create mode 100644 src/Werkr.Server/Components/Pages/Agents/Index.razor create mode 100644 src/Werkr.Server/Components/Pages/ApiKeys.razor create mode 100644 src/Werkr.Server/Components/Pages/Dashboard/Calendar.razor create mode 100644 src/Werkr.Server/Components/Pages/Dashboard/Calendar.razor.css create mode 100644 src/Werkr.Server/Components/Pages/Dashboard/TaskList.razor create mode 100644 src/Werkr.Server/Components/Pages/Error.razor create mode 100644 src/Werkr.Server/Components/Pages/HolidayCalendars/Create.razor create mode 100644 src/Werkr.Server/Components/Pages/HolidayCalendars/Detail.razor create mode 100644 src/Werkr.Server/Components/Pages/HolidayCalendars/Edit.razor create mode 100644 src/Werkr.Server/Components/Pages/HolidayCalendars/Index.razor create mode 100644 src/Werkr.Server/Components/Pages/Home.razor create mode 100644 src/Werkr.Server/Components/Pages/Jobs/Detail.razor create mode 100644 src/Werkr.Server/Components/Pages/Jobs/Index.razor create mode 100644 src/Werkr.Server/Components/Pages/Register.razor create mode 100644 src/Werkr.Server/Components/Pages/Schedules/Create.razor create mode 100644 src/Werkr.Server/Components/Pages/Schedules/Edit.razor create mode 100644 src/Werkr.Server/Components/Pages/Schedules/Index.razor create mode 100644 src/Werkr.Server/Components/Pages/Settings.razor create mode 100644 src/Werkr.Server/Components/Pages/Tasks/Create.razor create mode 100644 src/Werkr.Server/Components/Pages/Tasks/Edit.razor create mode 100644 src/Werkr.Server/Components/Pages/Tasks/Index.razor create mode 100644 src/Werkr.Server/Components/Pages/Workflows/Create.razor create mode 100644 src/Werkr.Server/Components/Pages/Workflows/Dag.razor create mode 100644 src/Werkr.Server/Components/Pages/Workflows/Edit.razor create mode 100644 src/Werkr.Server/Components/Pages/Workflows/Index.razor create mode 100644 src/Werkr.Server/Components/Pages/Workflows/RunDetail.razor create mode 100644 src/Werkr.Server/Components/Pages/Workflows/Runs.razor create mode 100644 src/Werkr.Server/Components/Routes.razor create mode 100644 src/Werkr.Server/Components/Shared/ActionParameterEditor.razor create mode 100644 src/Werkr.Server/Components/Shared/Breadcrumb.razor create mode 100644 src/Werkr.Server/Components/Shared/ConfirmDialog.razor create mode 100644 src/Werkr.Server/Components/Shared/DagView.razor create mode 100644 src/Werkr.Server/Components/Shared/DagView.razor.css create mode 100644 src/Werkr.Server/Components/Shared/EmptyState.razor create mode 100644 src/Werkr.Server/Components/Shared/LoadingSpinner.razor create mode 100644 src/Werkr.Server/Components/Shared/StatusBanner.razor create mode 100644 src/Werkr.Server/Components/Shared/TagInput.razor create mode 100644 src/Werkr.Server/Components/Shared/ToggleSwitch.razor create mode 100644 src/Werkr.Server/Components/_Imports.razor create mode 100644 src/Werkr.Server/Dockerfile create mode 100644 src/Werkr.Server/Endpoints/AuthEndpoints.cs create mode 100644 src/Werkr.Server/Helpers/AgentDisplayHelper.cs create mode 100644 src/Werkr.Server/Helpers/UrlValidator.cs create mode 100644 src/Werkr.Server/Identity/ApiKeyService.cs create mode 100644 src/Werkr.Server/Identity/AuthForwardingHandler.cs create mode 100644 src/Werkr.Server/Identity/IdentitySeeder.cs create mode 100644 src/Werkr.Server/Identity/JwtTokenService.cs create mode 100644 src/Werkr.Server/Identity/WerkrCookieAuthEvents.cs create mode 100644 src/Werkr.Server/Program.cs create mode 100644 src/Werkr.Server/Services/ActionParameterRegistry.cs create mode 100644 src/Werkr.Server/Services/AgentHealthMonitorService.cs create mode 100644 src/Werkr.Server/Services/ServerConfigCache.cs create mode 100644 src/Werkr.Server/Utilities/SchedulePreviewCalculator.cs create mode 100644 src/Werkr.Server/Werkr.Server.csproj create mode 100644 src/Werkr.Server/appsettings.Development.json create mode 100644 src/Werkr.Server/appsettings.json create mode 100644 src/Werkr.Server/packages.lock.json create mode 100644 src/Werkr.Server/wwwroot/app.css create mode 100644 src/Werkr.Server/wwwroot/css/theme.css create mode 100644 src/Werkr.Server/wwwroot/favicon.png create mode 100644 src/Werkr.Server/wwwroot/fonts/CascadiaMonoNF.woff2 create mode 100644 src/Werkr.Server/wwwroot/js/toggle-theme.js create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/js/bootstrap.js create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js create mode 100644 src/Werkr.Server/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map create mode 100644 src/Werkr.ServiceDefaults/Extensions.cs create mode 100644 src/Werkr.ServiceDefaults/Werkr.ServiceDefaults.csproj create mode 100644 src/Werkr.ServiceDefaults/packages.lock.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..10fdcd8 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "gitversion.tool": { + "version": "6.6.0", + "commands": ["dotnet-gitversion"], + "rollForward": false + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ca4f403 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +**/bin/ +**/obj/ +**/.vs/ +**/node_modules/ +**/.git/ +**/logs/ +**/TestResults/ +**/*.user +**/*.suo +**/wwwroot/lib/bootstrap/dist/js/*.map +src/Test/ +src/Werkr.AppHost/ +docs/ +*.md +.editorconfig +.gitignore +scripts/ +.github/ +.docker-cache/ +.gitversion-cache/ + +# Allow Publish/ through for .deb build mode +!Publish/ +.config/ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f52b433 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,393 @@ +# top-most EditorConfig file +root = true + +# Don't use tabs for indentation. +[*] +indent_style = space +end_of_line = lf +# (Please don't specify an indent_size here; that has too many unintended consequences.) + +# Code files +[*.{cs,csx,vb,vbx}] +indent_size = 4 +insert_final_newline = true +charset = utf-8 + +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 4 +charset = utf-8 + +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +# JSON files +[*.json] +charset = utf-8 +indent_size = 2 + +# Powershell files +[*.ps1] +charset = utf-8 +indent_size = 4 + +# Shell script files +[*.sh] +charset = utf-8 +indent_size = 2 + +# Dotnet code style settings: +[*.{cs,vb}] + +# IDE0055: Fix formatting +dotnet_diagnostic.IDE0055.severity = warning + +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +# Avoid "this." and "Me." if not necessary +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent + +# Use language keywords instead of framework type names for type references +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Suggest more modern language features when available +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion + +# Non-private static fields are PascalCase +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields +dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style + +dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_static_fields.required_modifiers = static + +dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case + +# Non-private readonly fields are PascalCase +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields +dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = non_private_static_field_style + +dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected +dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly + +dotnet_naming_style.non_private_readonly_field_style.capitalization = pascal_case + +# Constants are PascalCase +dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants +dotnet_naming_rule.constants_should_be_pascal_case.style = non_private_static_field_style + +dotnet_naming_symbols.constants.applicable_kinds = field, local +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_style.constant_style.capitalization = pascal_case + +# Static fields are camelCase and start with s_ +dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion +dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields +dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style + +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static + +dotnet_naming_style.static_field_style.capitalization = camel_case +dotnet_naming_style.static_field_style.required_prefix = s_ + +# Instance fields are camelCase and start with _ +dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion +dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields +dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style + +dotnet_naming_symbols.instance_fields.applicable_kinds = field + +dotnet_naming_style.instance_field_style.capitalization = camel_case +dotnet_naming_style.instance_field_style.required_prefix = _ + +# Locals and parameters are camelCase +dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion +dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters +dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style + +dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local + +dotnet_naming_style.camel_case_style.capitalization = camel_case + +# Local functions are PascalCase +dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascal_case.style = non_private_static_field_style + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function + +dotnet_naming_style.local_function_style.capitalization = pascal_case + +# By default, name items with PascalCase +dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members +dotnet_naming_rule.members_should_be_pascal_case.style = non_private_static_field_style + +dotnet_naming_symbols.all_members.applicable_kinds = * + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# error RS2008: Enable analyzer release tracking for the analyzer project containing rule '{0}' +dotnet_diagnostic.RS2008.severity = none + +# IDE0035: Remove unreachable code +dotnet_diagnostic.IDE0035.severity = warning + +# IDE0036: Order modifiers +dotnet_diagnostic.IDE0036.severity = warning + +# IDE0043: Format string contains invalid placeholder +dotnet_diagnostic.IDE0043.severity = warning + +# IDE0044: Make field readonly +dotnet_diagnostic.IDE0044.severity = warning + +# RS0016: Only enable if API files are present +dotnet_public_api_analyzer.require_api_files = true +dotnet_style_readonly_field= true:silent +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +end_of_line = lf +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_diagnostic.CA1838.severity = suggestion +dotnet_diagnostic.CA1848.severity = suggestion +dotnet_diagnostic.CA1873.severity = suggestion +dotnet_diagnostic.CA5350.severity = error +dotnet_diagnostic.CA5351.severity = error +dotnet_diagnostic.CA5359.severity = warning +dotnet_diagnostic.CA5360.severity = warning +dotnet_diagnostic.CA5364.severity = error +dotnet_diagnostic.CA5365.severity = suggestion +dotnet_diagnostic.CA5384.severity = warning +dotnet_diagnostic.CA5385.severity = warning +dotnet_diagnostic.CA5397.severity = error +dotnet_diagnostic.CA2201.severity = warning +dotnet_diagnostic.CA2251.severity = suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_allow_multiple_blank_lines_experimental = true:silent +dotnet_style_allow_statement_immediately_after_block_experimental = true:silent +dotnet_code_quality_unused_parameters = all:suggestion +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent + +# CSharp code style settings: +[*.cs] +# Newline settings +csharp_new_line_before_open_brace =false +csharp_new_line_before_else =false +csharp_new_line_before_catch =false +csharp_new_line_before_finally =false +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces =false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Prefer "var" everywhere +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = false:suggestion +csharp_style_var_elsewhere = false:suggestion + +# Prefer method-like constructs to have a block body +csharp_style_expression_bodied_methods = true:none +csharp_style_expression_bodied_constructors = true:none +csharp_style_expression_bodied_operators = true:none + +# Prefer property-like constructs to have an expression-body +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +# Suggest more modern language features when available +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = true +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = true +csharp_space_between_method_declaration_empty_parameter_list_parentheses = true +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = true +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Blocks are allowed +csharp_prefer_braces = true:silent +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true +csharp_style_expression_bodied_lambdas= true:silent +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_expression_bodied_local_functions = true:silent +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +dotnet_diagnostic.CA1805.severity = warning +dotnet_diagnostic.CA1869.severity = warning +dotnet_diagnostic.CA1873.severity = warning +dotnet_diagnostic.CA2016.severity = warning +dotnet_diagnostic.IDE0004.severity = suggestion +dotnet_diagnostic.IDE0005.severity = suggestion +dotnet_diagnostic.IDE0008.severity = suggestion +dotnet_diagnostic.IDE0016.severity = suggestion +dotnet_diagnostic.IDE0017.severity = suggestion +dotnet_diagnostic.IDE0020.severity = suggestion +dotnet_diagnostic.IDE0019.severity = suggestion +dotnet_diagnostic.IDE0018.severity = suggestion +dotnet_diagnostic.IDE0022.severity = suggestion +dotnet_diagnostic.IDE0023.severity = suggestion +dotnet_diagnostic.IDE0024.severity = suggestion +dotnet_diagnostic.IDE0025.severity = suggestion +dotnet_diagnostic.IDE0026.severity = suggestion +dotnet_diagnostic.IDE0027.severity = suggestion +dotnet_diagnostic.IDE0028.severity = suggestion +dotnet_diagnostic.IDE0029.severity = suggestion +dotnet_diagnostic.IDE0030.severity = suggestion +dotnet_diagnostic.IDE0031.severity = suggestion +dotnet_diagnostic.IDE0032.severity = suggestion +dotnet_diagnostic.IDE0034.severity = suggestion +dotnet_diagnostic.IDE0040.severity = suggestion +dotnet_diagnostic.IDE0041.severity = warning +dotnet_diagnostic.IDE0045.severity = suggestion +dotnet_diagnostic.IDE0046.severity = suggestion +dotnet_diagnostic.IDE0048.severity = suggestion +dotnet_diagnostic.IDE0054.severity = suggestion +dotnet_diagnostic.IDE0057.severity = suggestion +dotnet_diagnostic.IDE0056.severity = suggestion +dotnet_diagnostic.IDE0058.severity = warning +dotnet_diagnostic.IDE0059.severity = suggestion +dotnet_diagnostic.IDE0060.severity = warning +dotnet_diagnostic.IDE0063.severity = suggestion +dotnet_diagnostic.IDE0066.severity = suggestion +dotnet_diagnostic.IDE0071.severity = suggestion +dotnet_diagnostic.IDE0072.severity = suggestion +dotnet_diagnostic.IDE0075.severity = suggestion +dotnet_diagnostic.IDE0074.severity = suggestion +dotnet_diagnostic.IDE0090.severity = warning +dotnet_diagnostic.IDE0082.severity = suggestion +dotnet_diagnostic.IDE0083.severity = suggestion +dotnet_diagnostic.IDE0120.severity = suggestion +dotnet_diagnostic.IDE0270.severity = warning +dotnet_diagnostic.IDE0305.severity = warning +dotnet_diagnostic.IDE0330.severity = warning +dotnet_diagnostic.IDE2004.severity = suggestion +dotnet_diagnostic.IDE2002.severity = suggestion +dotnet_diagnostic.IDE2001.severity = suggestion +dotnet_diagnostic.IDE1006.severity = warning +dotnet_diagnostic.IDE0180.severity = suggestion +dotnet_diagnostic.MSTEST0037.severity = warning +dotnet_diagnostic.MSTEST0049.severity = warning +dotnet_diagnostic.MSTEST0058.severity = warning +dotnet_diagnostic.SYSLIB1045.severity = warning +csharp_prefer_static_local_function = true:suggestion +csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = false:silent +csharp_style_prefer_utf8_string_literals = true:suggestion + +[src/CodeStyle/**.{cs,vb}] +# warning RS0005: Do not use generic CodeAction.Create to create CodeAction +dotnet_diagnostic.RS0005.severity = none + +[src/{Analyzers,CodeStyle,Features,Workspaces,EditorFeatures, VisualStudio}/**/*.{cs,vb}] + +# IDE0011: Add braces +csharp_prefer_braces = when_multiline:warning +# NOTE: We need the below severity entry for Add Braces due to https://github.com/dotnet/roslyn/issues/44201 +dotnet_diagnostic.IDE0011.severity = warning + +# IDE0040: Add accessibility modifiers +dotnet_diagnostic.IDE0040.severity = warning + +# CONSIDER: Are IDE0051 and IDE0052 too noisy to be warnings for IDE editing scenarios? Should they be made build-only warnings? +# IDE0051: Remove unused private member +dotnet_diagnostic.IDE0051.severity = warning + +# IDE0052: Remove unread private member +dotnet_diagnostic.IDE0052.severity = warning + +# IDE0059: Unnecessary assignment to a value +dotnet_diagnostic.IDE0059.severity = warning + +# IDE0060: Remove unused parameter +dotnet_diagnostic.IDE0060.severity = warning + +# CA1822: Make member static +dotnet_diagnostic.CA1822.severity = warning + +# Prefer "var" everywhere +dotnet_diagnostic.IDE0007.severity = warning +csharp_style_var_for_built_in_types = true:warning +csharp_style_var_when_type_is_apparent = true:warning +csharp_style_var_elsewhere = true:warning + +[src/{VisualStudio}/**/*.{cs,vb}] +# CA1822: Make member static +# Not enforced as a build 'warning' for 'VisualStudio' layer due to large number of false positives from https://github.com/dotnet/roslyn-analyzers/issues/3857 and https://github.com/dotnet/roslyn-analyzers/issues/3858 +# Additionally, there is a risk of accidentally breaking an internal API that partners rely on though IVT. +dotnet_diagnostic.CA1822.severity = suggestion diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b745930 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Build & Test + runs-on: ubuntu-latest + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore tools + run: dotnet tool restore + + - name: Determine version + id: version + run: | + VERSION_JSON=$(dotnet gitversion /output json) + echo "semVer=$(echo $VERSION_JSON | jq -r '.SemVer')" >> $GITHUB_OUTPUT + echo "assemblySemVer=$(echo $VERSION_JSON | jq -r '.AssemblySemVer')" >> $GITHUB_OUTPUT + echo "assemblySemFileVer=$(echo $VERSION_JSON | jq -r '.AssemblySemFileVer')" >> $GITHUB_OUTPUT + echo "informationalVersion=$(echo $VERSION_JSON | jq -r '.InformationalVersion')" >> $GITHUB_OUTPUT + + - name: Restore (locked-mode) + run: dotnet restore Werkr.slnx --locked-mode + + - name: Build + run: > + dotnet build Werkr.slnx -c Release --no-restore + /p:Version=${{ steps.version.outputs.semVer }} + /p:AssemblyVersion=${{ steps.version.outputs.assemblySemVer }} + /p:FileVersion=${{ steps.version.outputs.assemblySemFileVer }} + /p:InformationalVersion="${{ steps.version.outputs.informationalVersion }}" + + - name: Test + run: > + dotnet test --solution Werkr.slnx -c Release --no-build + --logger "trx;LogFileName=results.trx" + --results-directory TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: TestResults/**/*.trx diff --git a/.gitignore b/.gitignore index 98caeda..d741f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,127 @@ -############### -# folder # -############### -/**/DROP/ -/**/TEMP/ -/**/packages/ -/**/bin/ -/**/obj/ -# Ignore auto generated api documentation - this is automatically pushed to the documentation page via github actions. -/**/api/*.yml -/**/api/.manifest -_site - -# Cloned Project repos -/src/Werkr.Agent/** -/src/Werkr.Common/** -/src/Werkr.Common.Configuration/** -/src/Werkr.Installers/** -/src/Werkr.Server/** - -# Github Actions (or manual docs testing) copied files (these all also exist in the root of the repo or the docs/docfx dir). -/docs/index.md -/docs/LICENSE.md -/docs/_config.yml -/docs/CNAME -/docs/docfx.json -/docs/filterConfig.yml -/docs/README.md -/docs/toc.yml -/docs/templates +## A streamlined .gitignore for modern .NET projects +## including temporary files, build results, and +## files generated by popular .NET tools. If you are +## developing with Visual Studio, the VS .gitignore +## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +## has more thorough IDE-specific entries. +## +## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ +publish/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg + +# Others +~$* +*~ +CodeCoverage/ + +# MSBuild Binary and Structured Log +*.binlog + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Local Development Feature Documentation +docs/projects/* + +# Github Personal Configurations +.github/agents/* + +# Visual Studio Code Configuration +.vscode/* + +# Visual Studio Configuration +.vs/* + +# Launch Settings +**/Properties/launchSettings.json + +# Apple Private Keys +*.[Pp]8 + +# SQLite Database Files +*.db +*.db-shm +*.db-wal + +# DocFX output +_site/ +docs/api/ +docs/index.md +obj/.cache/ + +# Docker and environment files +.env +docker-compose.override.yml + +# Aspire publish output +aspire-output/ + +# Secrets directory +secrets/ + +# Caddy data +caddy-data/ +caddy-config/ +caddy-logs/ + +# macOS file +.DS_Store + +# EF Core BuildHost directories +**/BuildHost-net472/ +**/BuildHost-netcore/ + +# Publish output +Publish/ + +# WiX build output +src/Installer/**/bin/ +src/Installer/**/obj/ + +# Docker build cache +.docker-cache/ + +# Generated TLS certificates +certs/ +keys/ + +# GitVersion cache +.gitversion-cache/ + +# werkr output +job-output/ +logs/ \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1b31ba8 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,22 @@ + + + + net10.0 + https://github.com/DarkgreyDevelopment/Werkr.App + enable + enable + true + true + true + embedded + true + + + + $(GitVersion_SemVer) + $(GitVersion_AssemblySemVer) + $(GitVersion_AssemblySemFileVer) + $(GitVersion_InformationalVersion) + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..1b97c66 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,70 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..8df8fdc --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,41 @@ +workflow: GitHubFlow/v1 +mode: ContinuousDeployment +tag-prefix: 'v' +major-version-bump-message: "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\\(.*\\))?!:|BREAKING CHANGE" +minor-version-bump-message: "^feat(\\(.*\\))?:" +patch-version-bump-message: "^fix(\\(.*\\))?:" +commit-date-format: "yyyy-MM-dd" +assembly-versioning-scheme: MajorMinorPatch +assembly-file-versioning-scheme: MajorMinorPatch +branches: + main: + regex: ^main$ + label: '' + increment: Patch + prevent-increment: + when-current-commit-tagged: true + track-merge-target: false + is-release-branch: true + develop: + regex: ^develop$ + label: alpha + increment: Minor + feature: + regex: ^features?[/-] + label: '{BranchName}' + increment: Inherit + pull-request: + regex: ^(pull|pull\-requests|pr)[/-] + label: pr.{BranchName} + increment: Inherit + release: + regex: ^releases?[/-] + label: rc + increment: None + is-release-branch: true + hotfix: + regex: ^hotfix(es)?[/-] + label: hotfix + increment: Patch +ignore: + sha: [] diff --git a/LICENSE b/LICENSE index 3c79626..019371d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2023 Darkgrey Development - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2023 Darkgrey Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index cb4e433..6dd71f4 100644 --- a/README.md +++ b/README.md @@ -1,221 +1,221 @@ -# Werkr - An open source task automation and workflow orchestration project. - -Introducing the Werkr Task Automation Project - your one-stop shop for task automation and workflow orchestration. - -Werkr Logo & Text - -Whether you need a simple task scheduler/cron replacement or a comprehensive task orchestration platform, the Werkr -project has got you covered. Revolutionize the way you automate tasks and orchestrate workflows with our user-friendly -platform, designed to meet a wide range of automation needs. - -The Werkr project has two primary components: a Server and an Agent. -Both the Server and Agent are supported on a diverse set of operating systems and system architectures. -Currently, both Windows 10+ and Debian Linux based platforms (with systemd) are supported on both x64 and -arm64 cpu architectures. MacOS support is also planned for sometime after the .NET 8 release in November 2023. - -
- -# Streamlined Task Management: -With the Werkr project, you can predefine tasks to run on a schedule, create ad-hoc tasks to run immediately, -set tasks to run within a specific time frame, along with so many more configurable options. The choice is yours! - -Visit [docs.werkr.app](https://docs.werkr.app) to explore the Werkr Task Automation Project. - -
- -# Downloads: -- [Server Downloads](https://server.werkr.app/releases/latest) -- [Agent Downloads](https://agent.werkr.app/releases/latest) - -Both server and agent are offered for download in portable and installer form. Once installed there is no difference between the two versions. - -For users windows, download the latest msi installer for your cpu architecture (probably x64). -For users with Debian linux based operating systems (that have systemd enabled), select the latest .deb file -for your cpu architecture. -When in doubt select the x64 version. - - -

- - -# Documentation and Support: -* [Quick Start Guide](#quick-start-guide) -* [How To Articles](https://docs.werkr.app/articles/HowTo/index.html) -* [API Documentation](https://docs.werkr.app/api/index.html) -* Troubleshooting Guide (coming soon!) -* [Contributors Guide](#contributing) -* FAQ (coming soon!) - - -

- - -# Werkr Server/Agent features: - -## A Workflow-Centric Design: -The Werkr project primarily operates on a workflow, or directed acyclic graph (DAG), model. -The workflow model and DAG visualizations allow you to easily create and manage series of interconnected tasks. - -
- -## Schedulable Tasks: -Tasks are the fundamental building blocks of your automation workflows. -Tasks can be scheduled to run inside or outside of a workflow. - * Tasks outside a workflow can be scheduled to run at specific times, on pre-defined and cyclical schedules. - * Tasks ran inside a workflow have additional trigger mechanisms[*](#flexible-task-triggers). - -
- -## Versatile Task Types: -Choose from five primary task types to build your workflow(s): - - -### System-Defined Tasks: -Perform common operations like file and directory manipulation with ease, thanks to Werkr's prebuilt system tasks. -Enjoy consistent output parameters and error handling for the following operations: - * File and directory creation. - * Moving and copying files and directories. - * Deleting files and directories. - * Determine whether files or directories exist. - * Write pre-defined and dynamic content to a file. - - -### PowerShell Script Execution: -Run PowerShell scripts effortlessly and receive standard PowerShell outputs. - - -### PowerShell Command Execution: -Execute PowerShell commands and access standard PowerShell outputs. - - -### System Shell Command Execution: -Run commands in your operating system's native shell and get the exit code from the command execution. - - -### User-Defined Tasks: -Customize your workflows by creating your own tasks. Combine system-defined tasks, PowerShell scripts or commands, -and native command executions into your own free-form repeatable task. -Branch, iterate, and handle exceptions with ease! - -
- -## Flexible Task Triggers: -Start your tasks using various triggers, or combinations of triggers, including: - * FileWatch - * Monitor file system events in real-time or by polling a path periodically. - * DateTime - * Set a specific time to run your tasks. - * On an Interval/Cyclically - * Run tasks periodically. - * Task Completion States - * Trigger tasks based on the completion state of other tasks within the same workflow. - * Workflow Completion State - * Trigger tasks based on the operating state of an outside workflow. - - -

- - -# Example Use Cases: -* (Placeholder) - - -

- - -# Security: -The Werkr project has a wide variety of very powerful tools. So, security is taken quite seriously and there are some -mandatory steps that must be taken to set up the server and agent initially. - -* TLS certificates are mandatory for the scheduler and agent. -* The server and agent undergo an API key registration process before tasks can be run on the system. - * The agent generates an API key on first startup (and upon request thereafter). The generated API key must be - registered with a server within 12 hours of its generation. - * The server and agent then perform a mutual registration process using the API key where they record the opposing - parties' certificate information (ex HSTS information?). - -## Additional Security Considerations: -* Access Control - * The scheduler has built-in user roles that make it easy to restrict access to key and sensitive parts of the system. -* Allowed Hosts - * Both the scheduler and agent can restrict access via an allowed hosts list. -* Native 2fa support (TOTP) is built in to the scheduler. - - -

- - -# Licensing and Support: -The Werkr Task Automation Project is offered free of charge, without any warranties, under an -[MIT license](https://docs.werkr.app/LICENSE.html)! -Unfortunately, it does not come with any form of guaranteed or implied support. -Best effort support and triage will be offered on a volunteer basis via a -[GitHub issue](https://werkr.App/issues/new/choose) process. - - -

- - -# Quick Start Guide: -* (Placeholder) -* Example 1: ... -* Example 2: ... - - -

- - -# Contributing: -The Werkr Task Automation Project is in its early stages and we're excited that you're interested in contributing! -We believe that open collaboration is key to the project's success and growth. -We welcome contributions from developers, users, and anyone interested in task automation and workflow orchestration. - -All official project collaboration will occur via -[GitHub issues](https://werkr.App/issues/new/choose) or [discussions](https://werkr.App/discussions). - -The project has been split into multiple different repositories to keep thing more specific and focused, -so when looking for code please be aware of the following repositories. -* [Werkr.App](https://werkr.App) - * The primary documentation repository. Also hosts github pages. -* [Werkr.Server](https://server.werkr.app) - * The scheduler and primary UI interface for the project. -* [Werkr.Agent](https://agent.werkr.app) - * The agent software that performs the requested tasks. -* [Werkr.Common](https://common.werkr.app) - * A shared library used by both the Werkr Server and Agent. -* [Werkr.Common.Configuration](https://commonconfiguration.werkr.app) - * A shared configuration library used by both the Werkr Server and Agent. This is also used by the windows installer. -* [Werkr.Installers](https://installers.werkr.app) - * A shared [Wix](https://wixtoolset.org/) CustomAction library used by both the Werkr Server and Agent. - This library is used in the Msi install process. - -## Feedback, Suggestions, and Feature Requests: -Do you have an idea for a new feature or enhancement? We'd love to hear it! -As the project is still in its early stages, your feedback and suggestions are invaluable. -We encourage you to share your thoughts on features, improvements, and potential use cases. -You can submit your ideas by creating a -[new feature request](https://werkr.App/issues/new?template=feature_request.yaml). -Be sure to provide a clear description of your proposal and its potential benefits. - -## Documentation Improvements: -If you have suggestions for additional documentation, or corrections for existing documentation, then please submit a -[documentation improvement request](https://werkr.App/issues/new?template=improve_documentation.yaml). - -## Bug Reports: -Please report any bugs, performance issues, or security vulnerabilities you encounter while using the Werkr Task -Automation project by opening a -[new bug report](https://werkr.App/issues/new?&template=bug_report.yaml). -Be sure to include as much information as possible, such as steps to reproduce the issue, any error messages, -your system's configuration, and any additional context you think we should be aware of. - -## Code Contributions: -If you'd like to contribute code directly to the project, please fork the repository, create a new branch, and submit -a pull request with your changes. We encourage you to follow our existing coding style and conventions. -Make sure to include a detailed description of your changes in the pull request. - -Additionally you will need to agree to the -[Contribution License Agreement](https://werkr.App/issues/new?template=cla_agreement.yml) -before your PR will be merged. - -We appreciate all contributions, big or small, and look forward to building a vibrant and collaborative community -around the Werkr Task Automation Project. Thank you for your support! +# Werkr - An open source task automation and workflow orchestration project. + +Introducing the Werkr Task Automation Project - your one-stop shop for task automation and workflow orchestration. + +Werkr Logo & Text + +Whether you need a simple task scheduler/cron replacement or a comprehensive task orchestration platform, the Werkr +project has got you covered. Revolutionize the way you automate tasks and orchestrate workflows with our user-friendly +platform, designed to meet a wide range of automation needs. + +The Werkr project has two primary components: a Server and an Agent. +Both the Server and Agent are supported on a diverse set of operating systems and system architectures. +Currently, both Windows 10+ and Debian Linux based platforms (with systemd) are supported on both x64 and +arm64 cpu architectures. MacOS support is also planned for sometime after the .NET 8 release in November 2023. + +
+ +# Streamlined Task Management: +With the Werkr project, you can predefine tasks to run on a schedule, create ad-hoc tasks to run immediately, +set tasks to run within a specific time frame, along with so many more configurable options. The choice is yours! + +Visit [docs.werkr.app](https://docs.werkr.app) to explore the Werkr Task Automation Project. + +
+ +# Downloads: +- [Server Downloads](https://server.werkr.app/releases/latest) +- [Agent Downloads](https://agent.werkr.app/releases/latest) + +Both server and agent are offered for download in portable and installer form. Once installed there is no difference between the two versions. + +For users windows, download the latest msi installer for your cpu architecture (probably x64). +For users with Debian linux based operating systems (that have systemd enabled), select the latest .deb file +for your cpu architecture. +When in doubt select the x64 version. + + +

+ + +# Documentation and Support: +* [Quick Start Guide](#quick-start-guide) +* [How To Articles](https://docs.werkr.app/articles/HowTo/index.html) +* [API Documentation](https://docs.werkr.app/api/index.html) +* Troubleshooting Guide (coming soon!) +* [Contributors Guide](#contributing) +* FAQ (coming soon!) + + +

+ + +# Werkr Server/Agent features: + +## A Workflow-Centric Design: +The Werkr project primarily operates on a workflow, or directed acyclic graph (DAG), model. +The workflow model and DAG visualizations allow you to easily create and manage series of interconnected tasks. + +
+ +## Schedulable Tasks: +Tasks are the fundamental building blocks of your automation workflows. +Tasks can be scheduled to run inside or outside of a workflow. + * Tasks outside a workflow can be scheduled to run at specific times, on pre-defined and cyclical schedules. + * Tasks ran inside a workflow have additional trigger mechanisms[*](#flexible-task-triggers). + +
+ +## Versatile Task Types: +Choose from five primary task types to build your workflow(s): + + +### System-Defined Tasks: +Perform common operations like file and directory manipulation with ease, thanks to Werkr's prebuilt system tasks. +Enjoy consistent output parameters and error handling for the following operations: + * File and directory creation. + * Moving and copying files and directories. + * Deleting files and directories. + * Determine whether files or directories exist. + * Write pre-defined and dynamic content to a file. + + +### PowerShell Script Execution: +Run PowerShell scripts effortlessly and receive standard PowerShell outputs. + + +### PowerShell Command Execution: +Execute PowerShell commands and access standard PowerShell outputs. + + +### System Shell Command Execution: +Run commands in your operating system's native shell and get the exit code from the command execution. + + +### User-Defined Tasks: +Customize your workflows by creating your own tasks. Combine system-defined tasks, PowerShell scripts or commands, +and native command executions into your own free-form repeatable task. +Branch, iterate, and handle exceptions with ease! + +
+ +## Flexible Task Triggers: +Start your tasks using various triggers, or combinations of triggers, including: + * FileWatch + * Monitor file system events in real-time or by polling a path periodically. + * DateTime + * Set a specific time to run your tasks. + * On an Interval/Cyclically + * Run tasks periodically. + * Task Completion States + * Trigger tasks based on the completion state of other tasks within the same workflow. + * Workflow Completion State + * Trigger tasks based on the operating state of an outside workflow. + + +

+ + +# Example Use Cases: +* (Placeholder) + + +

+ + +# Security: +The Werkr project has a wide variety of very powerful tools. So, security is taken quite seriously and there are some +mandatory steps that must be taken to set up the server and agent initially. + +* TLS certificates are mandatory for the scheduler and agent. +* The server and agent undergo an API key registration process before tasks can be run on the system. + * The agent generates an API key on first startup (and upon request thereafter). The generated API key must be + registered with a server within 12 hours of its generation. + * The server and agent then perform a mutual registration process using the API key where they record the opposing + parties' certificate information (ex HSTS information?). + +## Additional Security Considerations: +* Access Control + * The scheduler has built-in user roles that make it easy to restrict access to key and sensitive parts of the system. +* Allowed Hosts + * Both the scheduler and agent can restrict access via an allowed hosts list. +* Native 2fa support (TOTP) is built in to the scheduler. + + +

+ + +# Licensing and Support: +The Werkr Task Automation Project is offered free of charge, without any warranties, under an +[MIT license](https://docs.werkr.app/LICENSE.html)! +Unfortunately, it does not come with any form of guaranteed or implied support. +Best effort support and triage will be offered on a volunteer basis via a +[GitHub issue](https://werkr.App/issues/new/choose) process. + + +

+ + +# Quick Start Guide: +* (Placeholder) +* Example 1: ... +* Example 2: ... + + +

+ + +# Contributing: +The Werkr Task Automation Project is in its early stages and we're excited that you're interested in contributing! +We believe that open collaboration is key to the project's success and growth. +We welcome contributions from developers, users, and anyone interested in task automation and workflow orchestration. + +All official project collaboration will occur via +[GitHub issues](https://werkr.App/issues/new/choose) or [discussions](https://werkr.App/discussions). + +The project has been split into multiple different repositories to keep thing more specific and focused, +so when looking for code please be aware of the following repositories. +* [Werkr.App](https://werkr.App) + * The primary documentation repository. Also hosts github pages. +* [Werkr.Server](https://server.werkr.app) + * The scheduler and primary UI interface for the project. +* [Werkr.Agent](https://agent.werkr.app) + * The agent software that performs the requested tasks. +* [Werkr.Common](https://common.werkr.app) + * A shared library used by both the Werkr Server and Agent. +* [Werkr.Common.Configuration](https://commonconfiguration.werkr.app) + * A shared configuration library used by both the Werkr Server and Agent. This is also used by the windows installer. +* [Werkr.Installers](https://installers.werkr.app) + * A shared [Wix](https://wixtoolset.org/) CustomAction library used by both the Werkr Server and Agent. + This library is used in the Msi install process. + +## Feedback, Suggestions, and Feature Requests: +Do you have an idea for a new feature or enhancement? We'd love to hear it! +As the project is still in its early stages, your feedback and suggestions are invaluable. +We encourage you to share your thoughts on features, improvements, and potential use cases. +You can submit your ideas by creating a +[new feature request](https://werkr.App/issues/new?template=feature_request.yaml). +Be sure to provide a clear description of your proposal and its potential benefits. + +## Documentation Improvements: +If you have suggestions for additional documentation, or corrections for existing documentation, then please submit a +[documentation improvement request](https://werkr.App/issues/new?template=improve_documentation.yaml). + +## Bug Reports: +Please report any bugs, performance issues, or security vulnerabilities you encounter while using the Werkr Task +Automation project by opening a +[new bug report](https://werkr.App/issues/new?&template=bug_report.yaml). +Be sure to include as much information as possible, such as steps to reproduce the issue, any error messages, +your system's configuration, and any additional context you think we should be aware of. + +## Code Contributions: +If you'd like to contribute code directly to the project, please fork the repository, create a new branch, and submit +a pull request with your changes. We encourage you to follow our existing coding style and conventions. +Make sure to include a detailed description of your changes in the pull request. + +Additionally you will need to agree to the +[Contribution License Agreement](https://werkr.App/issues/new?template=cla_agreement.yml) +before your PR will be merged. + +We appreciate all contributions, big or small, and look forward to building a vibrant and collaborative community +around the Werkr Task Automation Project. Thank you for your support! diff --git a/Werkr.slnx b/Werkr.slnx new file mode 100644 index 0000000..d8ea0ee --- /dev/null +++ b/Werkr.slnx @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..24df598 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,171 @@ +# --------------------------------------------------------------------------- +# Werkr — Docker Compose (Server + API + Agent + PostgreSQL) +# +# Usage: +# pwsh scripts/docker-build.ps1 # generates certs (first run) + builds images +# docker compose up -d # start all services +# docker compose down -v # stop & remove volumes +# +# For .deb-based builds (production): +# pwsh scripts/docker-build.ps1 -Deb +# docker compose up -d +# +# To override build mode via env: +# BUILD_MODE=deb docker compose build +# +# The Server UI is available at https://localhost:5050 +# Default admin credentials are seeded on first start. +# API: https://localhost:5001 Agent: https://localhost:5100 +# +# TLS certificates are generated by docker-build.ps1 into certs/. +# Control plane cert (Server + API): certs/werkr-server.pfx +# Agent cert: certs/werkr-agent.pfx +# CA cert (for verification): certs/werkr-ca.pem +# --------------------------------------------------------------------------- + +services: + # ---------- PostgreSQL ---------- + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: werkr + POSTGRES_PASSWORD: werkr_dev_password + POSTGRES_DB: werkrdb + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U werkr -d werkrdb"] + interval: 5s + timeout: 5s + retries: 10 + + # ---------- Werkr API ---------- + werkr-api: + image: ${DOCKER_REGISTRY:-ghcr.io/werkr}/werkr-api:${DOCKER_TAG:-latest} + platform: linux/amd64 + build: + context: . + dockerfile: src/Werkr.Api/Dockerfile + args: + BUILD_MODE: ${BUILD_MODE:-source} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + ASPNETCORE_ENVIRONMENT: Development + DOTNET_ENVIRONMENT: Development + ASPNETCORE_URLS: "https://+:8443" + ASPNETCORE_Kestrel__Certificates__Default__Path: /app/certs/werkr-server.pfx + ASPNETCORE_Kestrel__Certificates__Default__Password: werkr-dev + SSL_CERT_FILE: /app/certs/werkr-ca.pem + ConnectionStrings__werkrdb: "Host=postgres;Port=5432;Database=werkrdb;Username=werkr;Password=werkr_dev_password" + Werkr__ServerUrl: "https://werkr-api:8443" + Jwt__SigningKey: "werkr-dev-signing-key-do-not-use-in-production-min32chars!" + Jwt__Issuer: "werkr-api" + Jwt__Audience: "werkr" + WERKR_CONFIG_PATH: /app/config + volumes: + - api-config:/app/config + - ./certs/werkr-server.pfx:/app/certs/werkr-server.pfx:ro + - ./certs/werkr-ca.pem:/app/certs/werkr-ca.pem:ro + ports: + - "5001:8443" + healthcheck: + test: ["CMD", "curl", "--cacert", "/app/certs/werkr-ca.pem", "-f", "https://localhost:8443/health"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 45s + + # ---------- Werkr Agent ---------- + werkr-agent: + image: ${DOCKER_REGISTRY:-ghcr.io/werkr}/werkr-agent:${DOCKER_TAG:-latest} + platform: linux/amd64 + build: + context: . + dockerfile: src/Werkr.Agent/Dockerfile + args: + BUILD_MODE: ${BUILD_MODE:-source} + restart: unless-stopped + depends_on: + werkr-api: + condition: service_healthy + environment: + ASPNETCORE_ENVIRONMENT: Development + DOTNET_ENVIRONMENT: Development + ASPNETCORE_URLS: "https://+:8443" + ASPNETCORE_Kestrel__Certificates__Default__Path: /app/certs/werkr-agent.pfx + ASPNETCORE_Kestrel__Certificates__Default__Password: werkr-dev + SSL_CERT_FILE: /app/certs/werkr-ca.pem + Agent__Name: "Docker Agent" + Agent__EnablePowerShell: "true" + Agent__EnableSystemShell: "true" + Werkr__AgentUrl: "https://werkr-agent:8443" + WERKR_CONFIG_PATH: /app/config + WERKR_DATA_DIR: /var/lib/werkr + JobOutput__OutputDirectory: /var/lib/werkr/job-output + volumes: + - agent-data:/var/lib/werkr + - agent-config:/app/config + - ./certs/werkr-agent.pfx:/app/certs/werkr-agent.pfx:ro + - ./certs/werkr-ca.pem:/app/certs/werkr-ca.pem:ro + ports: + - "5100:8443" + healthcheck: + test: ["CMD", "curl", "--cacert", "/app/certs/werkr-ca.pem", "-f", "https://localhost:8443/health"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 45s + + # ---------- Werkr Server (Blazor UI) ---------- + werkr-server: + image: ${DOCKER_REGISTRY:-ghcr.io/werkr}/werkr-server:${DOCKER_TAG:-latest} + platform: linux/amd64 + build: + context: . + dockerfile: src/Werkr.Server/Dockerfile + args: + BUILD_MODE: ${BUILD_MODE:-source} + restart: unless-stopped + depends_on: + werkr-api: + condition: service_healthy + werkr-agent: + condition: service_healthy + environment: + ASPNETCORE_ENVIRONMENT: Development + DOTNET_ENVIRONMENT: Development + ASPNETCORE_URLS: "https://+:8443" + ASPNETCORE_Kestrel__Certificates__Default__Path: /app/certs/werkr-server.pfx + ASPNETCORE_Kestrel__Certificates__Default__Password: werkr-dev + SSL_CERT_FILE: /app/certs/werkr-ca.pem + ConnectionStrings__werkrdb: "Host=postgres;Port=5432;Database=werkrdb;Username=werkr;Password=werkr_dev_password" + services__api__https__0: "https://werkr-api:8443" + services__agent__https__0: "https://werkr-agent:8443" + WERKR_CONFIG_PATH: /app/config + volumes: + - server-config:/app/config + - server-keys:/app/keys + - ./certs/werkr-server.pfx:/app/certs/werkr-server.pfx:ro + - ./certs/werkr-ca.pem:/app/certs/werkr-ca.pem:ro + ports: + - "5050:8443" + healthcheck: + test: ["CMD", "curl", "--cacert", "/app/certs/werkr-ca.pem", "-f", "https://localhost:8443/health"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 45s + +volumes: + pgdata: + agent-data: + agent-config: + api-config: + server-config: + server-keys: diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md index b07cdc8..957702c 100644 --- a/docs/CODE_OF_CONDUCT.md +++ b/docs/CODE_OF_CONDUCT.md @@ -1,128 +1,128 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -community@darkgrey.dev. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +community@darkgrey.dev. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 8b4719d..9da72e0 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,17 +1,17 @@ -# Security Policy - -## Supported Versions -| Version | Supported | -| ------- | ------------------ | -| 1.x.x | :white_check_mark: | -| < 1.0 | :x: | - -## Reporting a Vulnerability - -Please report vulnerabilities to [security@darkgrey.dev](mailto:security@darkgrey.dev). -Alternatively you may also open up -[an issue](https://github.com/DarkgreyDevelopment/Werkr.App/issues) on github. - -You should receive a response, within a week, through the same channel that you reported the vulnerability. - -If you'd like to propose a solution to the vulnerability you are also welcome to [contribute](https://docs.werkr.app/index.html#contributing)! +# Security Policy + +## Supported Versions +| Version | Supported | +| ------- | ------------------ | +| 1.x.x | :white_check_mark: | +| < 1.0 | :x: | + +## Reporting a Vulnerability + +Please report vulnerabilities to [security@darkgrey.dev](mailto:security@darkgrey.dev). +Alternatively you may also open up +[an issue](https://github.com/DarkgreyDevelopment/Werkr.App/issues) on github. + +You should receive a response, within a week, through the same channel that you reported the vulnerability. + +If you'd like to propose a solution to the vulnerability you are also welcome to [contribute](https://docs.werkr.app/index.html#contributing)! diff --git a/docs/docfx/templates/Werkr/fonts/glyphicons-halflings-regular.svg b/docs/docfx/templates/Werkr/fonts/glyphicons-halflings-regular.svg index 94fb549..8376c0f 100644 --- a/docs/docfx/templates/Werkr/fonts/glyphicons-halflings-regular.svg +++ b/docs/docfx/templates/Werkr/fonts/glyphicons-halflings-regular.svg @@ -1,288 +1,288 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/docfx/templates/Werkr/partials/affix.tmpl.partial b/docs/docfx/templates/Werkr/partials/affix.tmpl.partial index 11caeb3..2eb3279 100644 --- a/docs/docfx/templates/Werkr/partials/affix.tmpl.partial +++ b/docs/docfx/templates/Werkr/partials/affix.tmpl.partial @@ -1,40 +1,40 @@ -{{!Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See LICENSE file in the project root for full license information.}} - - +{{!Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See LICENSE file in the project root for full license information.}} + + diff --git a/docs/docfx/templates/Werkr/partials/footer.tmpl.partial b/docs/docfx/templates/Werkr/partials/footer.tmpl.partial index 4f311b6..deac6a2 100644 --- a/docs/docfx/templates/Werkr/partials/footer.tmpl.partial +++ b/docs/docfx/templates/Werkr/partials/footer.tmpl.partial @@ -1,37 +1,37 @@ -{{!Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See LICENSE file in the project root for full license information.}} - - +{{!Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See LICENSE file in the project root for full license information.}} + + diff --git a/docs/docfx/templates/Werkr/partials/head.tmpl.partial b/docs/docfx/templates/Werkr/partials/head.tmpl.partial index 625a8a0..9689280 100644 --- a/docs/docfx/templates/Werkr/partials/head.tmpl.partial +++ b/docs/docfx/templates/Werkr/partials/head.tmpl.partial @@ -1,19 +1,19 @@ -{{!Copyright (c) Oscar Vasquez. All rights reserved. Licensed under the MIT license. See LICENSE file in the project root for full license information.}} - - - - - {{#title}}{{title}}{{/title}}{{^title}}{{>partials/title}}{{/title}} {{#_appTitle}}| {{_appTitle}} {{/_appTitle}} - - - - {{#_description}}{{/_description}} - - - - - - {{#_noindex}}{{/_noindex}} - {{#_enableSearch}}{{/_enableSearch}} - {{#_enableNewTab}}{{/_enableNewTab}} - +{{!Copyright (c) Oscar Vasquez. All rights reserved. Licensed under the MIT license. See LICENSE file in the project root for full license information.}} + + + + + {{#title}}{{title}}{{/title}}{{^title}}{{>partials/title}}{{/title}} {{#_appTitle}}| {{_appTitle}} {{/_appTitle}} + + + + {{#_description}}{{/_description}} + + + + + + {{#_noindex}}{{/_noindex}} + {{#_enableSearch}}{{/_enableSearch}} + {{#_enableNewTab}}{{/_enableNewTab}} + diff --git a/docs/docfx/templates/Werkr/styles/lunr.js b/docs/docfx/templates/Werkr/styles/lunr.js index 35dae2f..cb0ed29 100644 --- a/docs/docfx/templates/Werkr/styles/lunr.js +++ b/docs/docfx/templates/Werkr/styles/lunr.js @@ -1,2924 +1,2924 @@ -/** - * lunr - http://lunrjs.com - A bit like Solr, but much smaller and not as bright - 2.1.2 - * Copyright (C) 2017 Oliver Nightingale - * @license MIT - */ - -;(function(){ - -/** - * A convenience function for configuring and constructing - * a new lunr Index. - * - * A lunr.Builder instance is created and the pipeline setup - * with a trimmer, stop word filter and stemmer. - * - * This builder object is yielded to the configuration function - * that is passed as a parameter, allowing the list of fields - * and other builder parameters to be customised. - * - * All documents _must_ be added within the passed config function. - * - * @example - * var idx = lunr(function () { - * this.field('title') - * this.field('body') - * this.ref('id') - * - * documents.forEach(function (doc) { - * this.add(doc) - * }, this) - * }) - * - * @see {@link lunr.Builder} - * @see {@link lunr.Pipeline} - * @see {@link lunr.trimmer} - * @see {@link lunr.stopWordFilter} - * @see {@link lunr.stemmer} - * @namespace {function} lunr - */ -var lunr = function (config) { - var builder = new lunr.Builder - - builder.pipeline.add( - lunr.trimmer, - lunr.stopWordFilter, - lunr.stemmer - ) - - builder.searchPipeline.add( - lunr.stemmer - ) - - config.call(builder, builder) - return builder.build() -} - -lunr.version = "2.1.2" -/*! - * lunr.utils - * Copyright (C) 2017 Oliver Nightingale - */ - -/** - * A namespace containing utils for the rest of the lunr library - */ -lunr.utils = {} - -/** - * Print a warning message to the console. - * - * @param {String} message The message to be printed. - * @memberOf Utils - */ -lunr.utils.warn = (function (global) { - /* eslint-disable no-console */ - return function (message) { - if (global.console && console.warn) { - console.warn(message) - } - } - /* eslint-enable no-console */ -})(this) - -/** - * Convert an object to a string. - * - * In the case of `null` and `undefined` the function returns - * the empty string, in all other cases the result of calling - * `toString` on the passed object is returned. - * - * @param {Any} obj The object to convert to a string. - * @return {String} string representation of the passed object. - * @memberOf Utils - */ -lunr.utils.asString = function (obj) { - if (obj === void 0 || obj === null) { - return "" - } else { - return obj.toString() - } -} -lunr.FieldRef = function (docRef, fieldName) { - this.docRef = docRef - this.fieldName = fieldName - this._stringValue = fieldName + lunr.FieldRef.joiner + docRef -} - -lunr.FieldRef.joiner = "/" - -lunr.FieldRef.fromString = function (s) { - var n = s.indexOf(lunr.FieldRef.joiner) - - if (n === -1) { - throw "malformed field ref string" - } - - var fieldRef = s.slice(0, n), - docRef = s.slice(n + 1) - - return new lunr.FieldRef (docRef, fieldRef) -} - -lunr.FieldRef.prototype.toString = function () { - return this._stringValue -} -/** - * A function to calculate the inverse document frequency for - * a posting. This is shared between the builder and the index - * - * @private - * @param {object} posting - The posting for a given term - * @param {number} documentCount - The total number of documents. - */ -lunr.idf = function (posting, documentCount) { - var documentsWithTerm = 0 - - for (var fieldName in posting) { - if (fieldName == '_index') continue // Ignore the term index, its not a field - documentsWithTerm += Object.keys(posting[fieldName]).length - } - - var x = (documentCount - documentsWithTerm + 0.5) / (documentsWithTerm + 0.5) - - return Math.log(1 + Math.abs(x)) -} - -/** - * A token wraps a string representation of a token - * as it is passed through the text processing pipeline. - * - * @constructor - * @param {string} [str=''] - The string token being wrapped. - * @param {object} [metadata={}] - Metadata associated with this token. - */ -lunr.Token = function (str, metadata) { - this.str = str || "" - this.metadata = metadata || {} -} - -/** - * Returns the token string that is being wrapped by this object. - * - * @returns {string} - */ -lunr.Token.prototype.toString = function () { - return this.str -} - -/** - * A token update function is used when updating or optionally - * when cloning a token. - * - * @callback lunr.Token~updateFunction - * @param {string} str - The string representation of the token. - * @param {Object} metadata - All metadata associated with this token. - */ - -/** - * Applies the given function to the wrapped string token. - * - * @example - * token.update(function (str, metadata) { - * return str.toUpperCase() - * }) - * - * @param {lunr.Token~updateFunction} fn - A function to apply to the token string. - * @returns {lunr.Token} - */ -lunr.Token.prototype.update = function (fn) { - this.str = fn(this.str, this.metadata) - return this -} - -/** - * Creates a clone of this token. Optionally a function can be - * applied to the cloned token. - * - * @param {lunr.Token~updateFunction} [fn] - An optional function to apply to the cloned token. - * @returns {lunr.Token} - */ -lunr.Token.prototype.clone = function (fn) { - fn = fn || function (s) { return s } - return new lunr.Token (fn(this.str, this.metadata), this.metadata) -} -/*! - * lunr.tokenizer - * Copyright (C) 2017 Oliver Nightingale - */ - -/** - * A function for splitting a string into tokens ready to be inserted into - * the search index. Uses `lunr.tokenizer.separator` to split strings, change - * the value of this property to change how strings are split into tokens. - * - * This tokenizer will convert its parameter to a string by calling `toString` and - * then will split this string on the character in `lunr.tokenizer.separator`. - * Arrays will have their elements converted to strings and wrapped in a lunr.Token. - * - * @static - * @param {?(string|object|object[])} obj - The object to convert into tokens - * @returns {lunr.Token[]} - */ -lunr.tokenizer = function (obj) { - if (obj == null || obj == undefined) { - return [] - } - - if (Array.isArray(obj)) { - return obj.map(function (t) { - return new lunr.Token(lunr.utils.asString(t).toLowerCase()) - }) - } - - var str = obj.toString().trim().toLowerCase(), - len = str.length, - tokens = [] - - for (var sliceEnd = 0, sliceStart = 0; sliceEnd <= len; sliceEnd++) { - var char = str.charAt(sliceEnd), - sliceLength = sliceEnd - sliceStart - - if ((char.match(lunr.tokenizer.separator) || sliceEnd == len)) { - - if (sliceLength > 0) { - tokens.push( - new lunr.Token (str.slice(sliceStart, sliceEnd), { - position: [sliceStart, sliceLength], - index: tokens.length - }) - ) - } - - sliceStart = sliceEnd + 1 - } - - } - - return tokens -} - -/** - * The separator used to split a string into tokens. Override this property to change the behaviour of - * `lunr.tokenizer` behaviour when tokenizing strings. By default this splits on whitespace and hyphens. - * - * @static - * @see lunr.tokenizer - */ -lunr.tokenizer.separator = /[\s\-]+/ -/*! - * lunr.Pipeline - * Copyright (C) 2017 Oliver Nightingale - */ - -/** - * lunr.Pipelines maintain an ordered list of functions to be applied to all - * tokens in documents entering the search index and queries being ran against - * the index. - * - * An instance of lunr.Index created with the lunr shortcut will contain a - * pipeline with a stop word filter and an English language stemmer. Extra - * functions can be added before or after either of these functions or these - * default functions can be removed. - * - * When run the pipeline will call each function in turn, passing a token, the - * index of that token in the original list of all tokens and finally a list of - * all the original tokens. - * - * The output of functions in the pipeline will be passed to the next function - * in the pipeline. To exclude a token from entering the index the function - * should return undefined, the rest of the pipeline will not be called with - * this token. - * - * For serialisation of pipelines to work, all functions used in an instance of - * a pipeline should be registered with lunr.Pipeline. Registered functions can - * then be loaded. If trying to load a serialised pipeline that uses functions - * that are not registered an error will be thrown. - * - * If not planning on serialising the pipeline then registering pipeline functions - * is not necessary. - * - * @constructor - */ -lunr.Pipeline = function () { - this._stack = [] -} - -lunr.Pipeline.registeredFunctions = Object.create(null) - -/** - * A pipeline function maps lunr.Token to lunr.Token. A lunr.Token contains the token - * string as well as all known metadata. A pipeline function can mutate the token string - * or mutate (or add) metadata for a given token. - * - * A pipeline function can indicate that the passed token should be discarded by returning - * null. This token will not be passed to any downstream pipeline functions and will not be - * added to the index. - * - * Multiple tokens can be returned by returning an array of tokens. Each token will be passed - * to any downstream pipeline functions and all will returned tokens will be added to the index. - * - * Any number of pipeline functions may be chained together using a lunr.Pipeline. - * - * @interface lunr.PipelineFunction - * @param {lunr.Token} token - A token from the document being processed. - * @param {number} i - The index of this token in the complete list of tokens for this document/field. - * @param {lunr.Token[]} tokens - All tokens for this document/field. - * @returns {(?lunr.Token|lunr.Token[])} - */ - -/** - * Register a function with the pipeline. - * - * Functions that are used in the pipeline should be registered if the pipeline - * needs to be serialised, or a serialised pipeline needs to be loaded. - * - * Registering a function does not add it to a pipeline, functions must still be - * added to instances of the pipeline for them to be used when running a pipeline. - * - * @param {lunr.PipelineFunction} fn - The function to check for. - * @param {String} label - The label to register this function with - */ -lunr.Pipeline.registerFunction = function (fn, label) { - if (label in this.registeredFunctions) { - lunr.utils.warn('Overwriting existing registered function: ' + label) - } - - fn.label = label - lunr.Pipeline.registeredFunctions[fn.label] = fn -} - -/** - * Warns if the function is not registered as a Pipeline function. - * - * @param {lunr.PipelineFunction} fn - The function to check for. - * @private - */ -lunr.Pipeline.warnIfFunctionNotRegistered = function (fn) { - var isRegistered = fn.label && (fn.label in this.registeredFunctions) - - if (!isRegistered) { - lunr.utils.warn('Function is not registered with pipeline. This may cause problems when serialising the index.\n', fn) - } -} - -/** - * Loads a previously serialised pipeline. - * - * All functions to be loaded must already be registered with lunr.Pipeline. - * If any function from the serialised data has not been registered then an - * error will be thrown. - * - * @param {Object} serialised - The serialised pipeline to load. - * @returns {lunr.Pipeline} - */ -lunr.Pipeline.load = function (serialised) { - var pipeline = new lunr.Pipeline - - serialised.forEach(function (fnName) { - var fn = lunr.Pipeline.registeredFunctions[fnName] - - if (fn) { - pipeline.add(fn) - } else { - throw new Error('Cannot load unregistered function: ' + fnName) - } - }) - - return pipeline -} - -/** - * Adds new functions to the end of the pipeline. - * - * Logs a warning if the function has not been registered. - * - * @param {lunr.PipelineFunction[]} functions - Any number of functions to add to the pipeline. - */ -lunr.Pipeline.prototype.add = function () { - var fns = Array.prototype.slice.call(arguments) - - fns.forEach(function (fn) { - lunr.Pipeline.warnIfFunctionNotRegistered(fn) - this._stack.push(fn) - }, this) -} - -/** - * Adds a single function after a function that already exists in the - * pipeline. - * - * Logs a warning if the function has not been registered. - * - * @param {lunr.PipelineFunction} existingFn - A function that already exists in the pipeline. - * @param {lunr.PipelineFunction} newFn - The new function to add to the pipeline. - */ -lunr.Pipeline.prototype.after = function (existingFn, newFn) { - lunr.Pipeline.warnIfFunctionNotRegistered(newFn) - - var pos = this._stack.indexOf(existingFn) - if (pos == -1) { - throw new Error('Cannot find existingFn') - } - - pos = pos + 1 - this._stack.splice(pos, 0, newFn) -} - -/** - * Adds a single function before a function that already exists in the - * pipeline. - * - * Logs a warning if the function has not been registered. - * - * @param {lunr.PipelineFunction} existingFn - A function that already exists in the pipeline. - * @param {lunr.PipelineFunction} newFn - The new function to add to the pipeline. - */ -lunr.Pipeline.prototype.before = function (existingFn, newFn) { - lunr.Pipeline.warnIfFunctionNotRegistered(newFn) - - var pos = this._stack.indexOf(existingFn) - if (pos == -1) { - throw new Error('Cannot find existingFn') - } - - this._stack.splice(pos, 0, newFn) -} - -/** - * Removes a function from the pipeline. - * - * @param {lunr.PipelineFunction} fn The function to remove from the pipeline. - */ -lunr.Pipeline.prototype.remove = function (fn) { - var pos = this._stack.indexOf(fn) - if (pos == -1) { - return - } - - this._stack.splice(pos, 1) -} - -/** - * Runs the current list of functions that make up the pipeline against the - * passed tokens. - * - * @param {Array} tokens The tokens to run through the pipeline. - * @returns {Array} - */ -lunr.Pipeline.prototype.run = function (tokens) { - var stackLength = this._stack.length - - for (var i = 0; i < stackLength; i++) { - var fn = this._stack[i] - - tokens = tokens.reduce(function (memo, token, j) { - var result = fn(token, j, tokens) - - if (result === void 0 || result === '') return memo - - return memo.concat(result) - }, []) - } - - return tokens -} - -/** - * Convenience method for passing a string through a pipeline and getting - * strings out. This method takes care of wrapping the passed string in a - * token and mapping the resulting tokens back to strings. - * - * @param {string} str - The string to pass through the pipeline. - * @returns {string[]} - */ -lunr.Pipeline.prototype.runString = function (str) { - var token = new lunr.Token (str) - - return this.run([token]).map(function (t) { - return t.toString() - }) -} - -/** - * Resets the pipeline by removing any existing processors. - * - */ -lunr.Pipeline.prototype.reset = function () { - this._stack = [] -} - -/** - * Returns a representation of the pipeline ready for serialisation. - * - * Logs a warning if the function has not been registered. - * - * @returns {Array} - */ -lunr.Pipeline.prototype.toJSON = function () { - return this._stack.map(function (fn) { - lunr.Pipeline.warnIfFunctionNotRegistered(fn) - - return fn.label - }) -} -/*! - * lunr.Vector - * Copyright (C) 2017 Oliver Nightingale - */ - -/** - * A vector is used to construct the vector space of documents and queries. These - * vectors support operations to determine the similarity between two documents or - * a document and a query. - * - * Normally no parameters are required for initializing a vector, but in the case of - * loading a previously dumped vector the raw elements can be provided to the constructor. - * - * For performance reasons vectors are implemented with a flat array, where an elements - * index is immediately followed by its value. E.g. [index, value, index, value]. This - * allows the underlying array to be as sparse as possible and still offer decent - * performance when being used for vector calculations. - * - * @constructor - * @param {Number[]} [elements] - The flat list of element index and element value pairs. - */ -lunr.Vector = function (elements) { - this._magnitude = 0 - this.elements = elements || [] -} - - -/** - * Calculates the position within the vector to insert a given index. - * - * This is used internally by insert and upsert. If there are duplicate indexes then - * the position is returned as if the value for that index were to be updated, but it - * is the callers responsibility to check whether there is a duplicate at that index - * - * @param {Number} insertIdx - The index at which the element should be inserted. - * @returns {Number} - */ -lunr.Vector.prototype.positionForIndex = function (index) { - // For an empty vector the tuple can be inserted at the beginning - if (this.elements.length == 0) { - return 0 - } - - var start = 0, - end = this.elements.length / 2, - sliceLength = end - start, - pivotPoint = Math.floor(sliceLength / 2), - pivotIndex = this.elements[pivotPoint * 2] - - while (sliceLength > 1) { - if (pivotIndex < index) { - start = pivotPoint - } - - if (pivotIndex > index) { - end = pivotPoint - } - - if (pivotIndex == index) { - break - } - - sliceLength = end - start - pivotPoint = start + Math.floor(sliceLength / 2) - pivotIndex = this.elements[pivotPoint * 2] - } - - if (pivotIndex == index) { - return pivotPoint * 2 - } - - if (pivotIndex > index) { - return pivotPoint * 2 - } - - if (pivotIndex < index) { - return (pivotPoint + 1) * 2 - } -} - -/** - * Inserts an element at an index within the vector. - * - * Does not allow duplicates, will throw an error if there is already an entry - * for this index. - * - * @param {Number} insertIdx - The index at which the element should be inserted. - * @param {Number} val - The value to be inserted into the vector. - */ -lunr.Vector.prototype.insert = function (insertIdx, val) { - this.upsert(insertIdx, val, function () { - throw "duplicate index" - }) -} - -/** - * Inserts or updates an existing index within the vector. - * - * @param {Number} insertIdx - The index at which the element should be inserted. - * @param {Number} val - The value to be inserted into the vector. - * @param {function} fn - A function that is called for updates, the existing value and the - * requested value are passed as arguments - */ -lunr.Vector.prototype.upsert = function (insertIdx, val, fn) { - this._magnitude = 0 - var position = this.positionForIndex(insertIdx) - - if (this.elements[position] == insertIdx) { - this.elements[position + 1] = fn(this.elements[position + 1], val) - } else { - this.elements.splice(position, 0, insertIdx, val) - } -} - -/** - * Calculates the magnitude of this vector. - * - * @returns {Number} - */ -lunr.Vector.prototype.magnitude = function () { - if (this._magnitude) return this._magnitude - - var sumOfSquares = 0, - elementsLength = this.elements.length - - for (var i = 1; i < elementsLength; i += 2) { - var val = this.elements[i] - sumOfSquares += val * val - } - - return this._magnitude = Math.sqrt(sumOfSquares) -} - -/** - * Calculates the dot product of this vector and another vector. - * - * @param {lunr.Vector} otherVector - The vector to compute the dot product with. - * @returns {Number} - */ -lunr.Vector.prototype.dot = function (otherVector) { - var dotProduct = 0, - a = this.elements, b = otherVector.elements, - aLen = a.length, bLen = b.length, - aVal = 0, bVal = 0, - i = 0, j = 0 - - while (i < aLen && j < bLen) { - aVal = a[i], bVal = b[j] - if (aVal < bVal) { - i += 2 - } else if (aVal > bVal) { - j += 2 - } else if (aVal == bVal) { - dotProduct += a[i + 1] * b[j + 1] - i += 2 - j += 2 - } - } - - return dotProduct -} - -/** - * Calculates the cosine similarity between this vector and another - * vector. - * - * @param {lunr.Vector} otherVector - The other vector to calculate the - * similarity with. - * @returns {Number} - */ -lunr.Vector.prototype.similarity = function (otherVector) { - return this.dot(otherVector) / (this.magnitude() * otherVector.magnitude()) -} - -/** - * Converts the vector to an array of the elements within the vector. - * - * @returns {Number[]} - */ -lunr.Vector.prototype.toArray = function () { - var output = new Array (this.elements.length / 2) - - for (var i = 1, j = 0; i < this.elements.length; i += 2, j++) { - output[j] = this.elements[i] - } - - return output -} - -/** - * A JSON serializable representation of the vector. - * - * @returns {Number[]} - */ -lunr.Vector.prototype.toJSON = function () { - return this.elements -} -/* eslint-disable */ -/*! - * lunr.stemmer - * Copyright (C) 2017 Oliver Nightingale - * Includes code from - http://tartarus.org/~martin/PorterStemmer/js.txt - */ - -/** - * lunr.stemmer is an english language stemmer, this is a JavaScript - * implementation of the PorterStemmer taken from http://tartarus.org/~martin - * - * @static - * @implements {lunr.PipelineFunction} - * @param {lunr.Token} token - The string to stem - * @returns {lunr.Token} - * @see {@link lunr.Pipeline} - */ -lunr.stemmer = (function(){ - var step2list = { - "ational" : "ate", - "tional" : "tion", - "enci" : "ence", - "anci" : "ance", - "izer" : "ize", - "bli" : "ble", - "alli" : "al", - "entli" : "ent", - "eli" : "e", - "ousli" : "ous", - "ization" : "ize", - "ation" : "ate", - "ator" : "ate", - "alism" : "al", - "iveness" : "ive", - "fulness" : "ful", - "ousness" : "ous", - "aliti" : "al", - "iviti" : "ive", - "biliti" : "ble", - "logi" : "log" - }, - - step3list = { - "icate" : "ic", - "ative" : "", - "alize" : "al", - "iciti" : "ic", - "ical" : "ic", - "ful" : "", - "ness" : "" - }, - - c = "[^aeiou]", // consonant - v = "[aeiouy]", // vowel - C = c + "[^aeiouy]*", // consonant sequence - V = v + "[aeiou]*", // vowel sequence - - mgr0 = "^(" + C + ")?" + V + C, // [C]VC... is m>0 - meq1 = "^(" + C + ")?" + V + C + "(" + V + ")?$", // [C]VC[V] is m=1 - mgr1 = "^(" + C + ")?" + V + C + V + C, // [C]VCVC... is m>1 - s_v = "^(" + C + ")?" + v; // vowel in stem - - var re_mgr0 = new RegExp(mgr0); - var re_mgr1 = new RegExp(mgr1); - var re_meq1 = new RegExp(meq1); - var re_s_v = new RegExp(s_v); - - var re_1a = /^(.+?)(ss|i)es$/; - var re2_1a = /^(.+?)([^s])s$/; - var re_1b = /^(.+?)eed$/; - var re2_1b = /^(.+?)(ed|ing)$/; - var re_1b_2 = /.$/; - var re2_1b_2 = /(at|bl|iz)$/; - var re3_1b_2 = new RegExp("([^aeiouylsz])\\1$"); - var re4_1b_2 = new RegExp("^" + C + v + "[^aeiouwxy]$"); - - var re_1c = /^(.+?[^aeiou])y$/; - var re_2 = /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/; - - var re_3 = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/; - - var re_4 = /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/; - var re2_4 = /^(.+?)(s|t)(ion)$/; - - var re_5 = /^(.+?)e$/; - var re_5_1 = /ll$/; - var re3_5 = new RegExp("^" + C + v + "[^aeiouwxy]$"); - - var porterStemmer = function porterStemmer(w) { - var stem, - suffix, - firstch, - re, - re2, - re3, - re4; - - if (w.length < 3) { return w; } - - firstch = w.substr(0,1); - if (firstch == "y") { - w = firstch.toUpperCase() + w.substr(1); - } - - // Step 1a - re = re_1a - re2 = re2_1a; - - if (re.test(w)) { w = w.replace(re,"$1$2"); } - else if (re2.test(w)) { w = w.replace(re2,"$1$2"); } - - // Step 1b - re = re_1b; - re2 = re2_1b; - if (re.test(w)) { - var fp = re.exec(w); - re = re_mgr0; - if (re.test(fp[1])) { - re = re_1b_2; - w = w.replace(re,""); - } - } else if (re2.test(w)) { - var fp = re2.exec(w); - stem = fp[1]; - re2 = re_s_v; - if (re2.test(stem)) { - w = stem; - re2 = re2_1b_2; - re3 = re3_1b_2; - re4 = re4_1b_2; - if (re2.test(w)) { w = w + "e"; } - else if (re3.test(w)) { re = re_1b_2; w = w.replace(re,""); } - else if (re4.test(w)) { w = w + "e"; } - } - } - - // Step 1c - replace suffix y or Y by i if preceded by a non-vowel which is not the first letter of the word (so cry -> cri, by -> by, say -> say) - re = re_1c; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - w = stem + "i"; - } - - // Step 2 - re = re_2; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - suffix = fp[2]; - re = re_mgr0; - if (re.test(stem)) { - w = stem + step2list[suffix]; - } - } - - // Step 3 - re = re_3; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - suffix = fp[2]; - re = re_mgr0; - if (re.test(stem)) { - w = stem + step3list[suffix]; - } - } - - // Step 4 - re = re_4; - re2 = re2_4; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - re = re_mgr1; - if (re.test(stem)) { - w = stem; - } - } else if (re2.test(w)) { - var fp = re2.exec(w); - stem = fp[1] + fp[2]; - re2 = re_mgr1; - if (re2.test(stem)) { - w = stem; - } - } - - // Step 5 - re = re_5; - if (re.test(w)) { - var fp = re.exec(w); - stem = fp[1]; - re = re_mgr1; - re2 = re_meq1; - re3 = re3_5; - if (re.test(stem) || (re2.test(stem) && !(re3.test(stem)))) { - w = stem; - } - } - - re = re_5_1; - re2 = re_mgr1; - if (re.test(w) && re2.test(w)) { - re = re_1b_2; - w = w.replace(re,""); - } - - // and turn initial Y back to y - - if (firstch == "y") { - w = firstch.toLowerCase() + w.substr(1); - } - - return w; - }; - - return function (token) { - return token.update(porterStemmer); - } -})(); - -lunr.Pipeline.registerFunction(lunr.stemmer, 'stemmer') -/*! - * lunr.stopWordFilter - * Copyright (C) 2017 Oliver Nightingale - */ - -/** - * lunr.generateStopWordFilter builds a stopWordFilter function from the provided - * list of stop words. - * - * The built in lunr.stopWordFilter is built using this generator and can be used - * to generate custom stopWordFilters for applications or non English languages. - * - * @param {Array} token The token to pass through the filter - * @returns {lunr.PipelineFunction} - * @see lunr.Pipeline - * @see lunr.stopWordFilter - */ -lunr.generateStopWordFilter = function (stopWords) { - var words = stopWords.reduce(function (memo, stopWord) { - memo[stopWord] = stopWord - return memo - }, {}) - - return function (token) { - if (token && words[token.toString()] !== token.toString()) return token - } -} - -/** - * lunr.stopWordFilter is an English language stop word list filter, any words - * contained in the list will not be passed through the filter. - * - * This is intended to be used in the Pipeline. If the token does not pass the - * filter then undefined will be returned. - * - * @implements {lunr.PipelineFunction} - * @params {lunr.Token} token - A token to check for being a stop word. - * @returns {lunr.Token} - * @see {@link lunr.Pipeline} - */ -lunr.stopWordFilter = lunr.generateStopWordFilter([ - 'a', - 'able', - 'about', - 'across', - 'after', - 'all', - 'almost', - 'also', - 'am', - 'among', - 'an', - 'and', - 'any', - 'are', - 'as', - 'at', - 'be', - 'because', - 'been', - 'but', - 'by', - 'can', - 'cannot', - 'could', - 'dear', - 'did', - 'do', - 'does', - 'either', - 'else', - 'ever', - 'every', - 'for', - 'from', - 'get', - 'got', - 'had', - 'has', - 'have', - 'he', - 'her', - 'hers', - 'him', - 'his', - 'how', - 'however', - 'i', - 'if', - 'in', - 'into', - 'is', - 'it', - 'its', - 'just', - 'least', - 'let', - 'like', - 'likely', - 'may', - 'me', - 'might', - 'most', - 'must', - 'my', - 'neither', - 'no', - 'nor', - 'not', - 'of', - 'off', - 'often', - 'on', - 'only', - 'or', - 'other', - 'our', - 'own', - 'rather', - 'said', - 'say', - 'says', - 'she', - 'should', - 'since', - 'so', - 'some', - 'than', - 'that', - 'the', - 'their', - 'them', - 'then', - 'there', - 'these', - 'they', - 'this', - 'tis', - 'to', - 'too', - 'twas', - 'us', - 'wants', - 'was', - 'we', - 'were', - 'what', - 'when', - 'where', - 'which', - 'while', - 'who', - 'whom', - 'why', - 'will', - 'with', - 'would', - 'yet', - 'you', - 'your' -]) - -lunr.Pipeline.registerFunction(lunr.stopWordFilter, 'stopWordFilter') -/*! - * lunr.trimmer - * Copyright (C) 2017 Oliver Nightingale - */ - -/** - * lunr.trimmer is a pipeline function for trimming non word - * characters from the beginning and end of tokens before they - * enter the index. - * - * This implementation may not work correctly for non latin - * characters and should either be removed or adapted for use - * with languages with non-latin characters. - * - * @static - * @implements {lunr.PipelineFunction} - * @param {lunr.Token} token The token to pass through the filter - * @returns {lunr.Token} - * @see lunr.Pipeline - */ -lunr.trimmer = function (token) { - return token.update(function (s) { - return s.replace(/^\W+/, '').replace(/\W+$/, '') - }) -} - -lunr.Pipeline.registerFunction(lunr.trimmer, 'trimmer') -/*! - * lunr.TokenSet - * Copyright (C) 2017 Oliver Nightingale - */ - -/** - * A token set is used to store the unique list of all tokens - * within an index. Token sets are also used to represent an - * incoming query to the index, this query token set and index - * token set are then intersected to find which tokens to look - * up in the inverted index. - * - * A token set can hold multiple tokens, as in the case of the - * index token set, or it can hold a single token as in the - * case of a simple query token set. - * - * Additionally token sets are used to perform wildcard matching. - * Leading, contained and trailing wildcards are supported, and - * from this edit distance matching can also be provided. - * - * Token sets are implemented as a minimal finite state automata, - * where both common prefixes and suffixes are shared between tokens. - * This helps to reduce the space used for storing the token set. - * - * @constructor - */ -lunr.TokenSet = function () { - this.final = false - this.edges = {} - this.id = lunr.TokenSet._nextId - lunr.TokenSet._nextId += 1 -} - -/** - * Keeps track of the next, auto increment, identifier to assign - * to a new tokenSet. - * - * TokenSets require a unique identifier to be correctly minimised. - * - * @private - */ -lunr.TokenSet._nextId = 1 - -/** - * Creates a TokenSet instance from the given sorted array of words. - * - * @param {String[]} arr - A sorted array of strings to create the set from. - * @returns {lunr.TokenSet} - * @throws Will throw an error if the input array is not sorted. - */ -lunr.TokenSet.fromArray = function (arr) { - var builder = new lunr.TokenSet.Builder - - for (var i = 0, len = arr.length; i < len; i++) { - builder.insert(arr[i]) - } - - builder.finish() - return builder.root -} - -/** - * Creates a token set from a query clause. - * - * @private - * @param {Object} clause - A single clause from lunr.Query. - * @param {string} clause.term - The query clause term. - * @param {number} [clause.editDistance] - The optional edit distance for the term. - * @returns {lunr.TokenSet} - */ -lunr.TokenSet.fromClause = function (clause) { - if ('editDistance' in clause) { - return lunr.TokenSet.fromFuzzyString(clause.term, clause.editDistance) - } else { - return lunr.TokenSet.fromString(clause.term) - } -} - -/** - * Creates a token set representing a single string with a specified - * edit distance. - * - * Insertions, deletions, substitutions and transpositions are each - * treated as an edit distance of 1. - * - * Increasing the allowed edit distance will have a dramatic impact - * on the performance of both creating and intersecting these TokenSets. - * It is advised to keep the edit distance less than 3. - * - * @param {string} str - The string to create the token set from. - * @param {number} editDistance - The allowed edit distance to match. - * @returns {lunr.Vector} - */ -lunr.TokenSet.fromFuzzyString = function (str, editDistance) { - var root = new lunr.TokenSet - - var stack = [{ - node: root, - editsRemaining: editDistance, - str: str - }] - - while (stack.length) { - var frame = stack.pop() - - // no edit - if (frame.str.length > 0) { - var char = frame.str.charAt(0), - noEditNode - - if (char in frame.node.edges) { - noEditNode = frame.node.edges[char] - } else { - noEditNode = new lunr.TokenSet - frame.node.edges[char] = noEditNode - } - - if (frame.str.length == 1) { - noEditNode.final = true - } else { - stack.push({ - node: noEditNode, - editsRemaining: frame.editsRemaining, - str: frame.str.slice(1) - }) - } - } - - // deletion - // can only do a deletion if we have enough edits remaining - // and if there are characters left to delete in the string - if (frame.editsRemaining > 0 && frame.str.length > 1) { - var char = frame.str.charAt(1), - deletionNode - - if (char in frame.node.edges) { - deletionNode = frame.node.edges[char] - } else { - deletionNode = new lunr.TokenSet - frame.node.edges[char] = deletionNode - } - - if (frame.str.length <= 2) { - deletionNode.final = true - } else { - stack.push({ - node: deletionNode, - editsRemaining: frame.editsRemaining - 1, - str: frame.str.slice(2) - }) - } - } - - // deletion - // just removing the last character from the str - if (frame.editsRemaining > 0 && frame.str.length == 1) { - frame.node.final = true - } - - // substitution - // can only do a substitution if we have enough edits remaining - // and if there are characters left to substitute - if (frame.editsRemaining > 0 && frame.str.length >= 1) { - if ("*" in frame.node.edges) { - var substitutionNode = frame.node.edges["*"] - } else { - var substitutionNode = new lunr.TokenSet - frame.node.edges["*"] = substitutionNode - } - - if (frame.str.length == 1) { - substitutionNode.final = true - } else { - stack.push({ - node: substitutionNode, - editsRemaining: frame.editsRemaining - 1, - str: frame.str.slice(1) - }) - } - } - - // insertion - // can only do insertion if there are edits remaining - if (frame.editsRemaining > 0) { - if ("*" in frame.node.edges) { - var insertionNode = frame.node.edges["*"] - } else { - var insertionNode = new lunr.TokenSet - frame.node.edges["*"] = insertionNode - } - - if (frame.str.length == 0) { - insertionNode.final = true - } else { - stack.push({ - node: insertionNode, - editsRemaining: frame.editsRemaining - 1, - str: frame.str - }) - } - } - - // transposition - // can only do a transposition if there are edits remaining - // and there are enough characters to transpose - if (frame.editsRemaining > 0 && frame.str.length > 1) { - var charA = frame.str.charAt(0), - charB = frame.str.charAt(1), - transposeNode - - if (charB in frame.node.edges) { - transposeNode = frame.node.edges[charB] - } else { - transposeNode = new lunr.TokenSet - frame.node.edges[charB] = transposeNode - } - - if (frame.str.length == 1) { - transposeNode.final = true - } else { - stack.push({ - node: transposeNode, - editsRemaining: frame.editsRemaining - 1, - str: charA + frame.str.slice(2) - }) - } - } - } - - return root -} - -/** - * Creates a TokenSet from a string. - * - * The string may contain one or more wildcard characters (*) - * that will allow wildcard matching when intersecting with - * another TokenSet. - * - * @param {string} str - The string to create a TokenSet from. - * @returns {lunr.TokenSet} - */ -lunr.TokenSet.fromString = function (str) { - var node = new lunr.TokenSet, - root = node, - wildcardFound = false - - /* - * Iterates through all characters within the passed string - * appending a node for each character. - * - * As soon as a wildcard character is found then a self - * referencing edge is introduced to continually match - * any number of any characters. - */ - for (var i = 0, len = str.length; i < len; i++) { - var char = str[i], - final = (i == len - 1) - - if (char == "*") { - wildcardFound = true - node.edges[char] = node - node.final = final - - } else { - var next = new lunr.TokenSet - next.final = final - - node.edges[char] = next - node = next - - // TODO: is this needed anymore? - if (wildcardFound) { - node.edges["*"] = root - } - } - } - - return root -} - -/** - * Converts this TokenSet into an array of strings - * contained within the TokenSet. - * - * @returns {string[]} - */ -lunr.TokenSet.prototype.toArray = function () { - var words = [] - - var stack = [{ - prefix: "", - node: this - }] - - while (stack.length) { - var frame = stack.pop(), - edges = Object.keys(frame.node.edges), - len = edges.length - - if (frame.node.final) { - words.push(frame.prefix) - } - - for (var i = 0; i < len; i++) { - var edge = edges[i] - - stack.push({ - prefix: frame.prefix.concat(edge), - node: frame.node.edges[edge] - }) - } - } - - return words -} - -/** - * Generates a string representation of a TokenSet. - * - * This is intended to allow TokenSets to be used as keys - * in objects, largely to aid the construction and minimisation - * of a TokenSet. As such it is not designed to be a human - * friendly representation of the TokenSet. - * - * @returns {string} - */ -lunr.TokenSet.prototype.toString = function () { - // NOTE: Using Object.keys here as this.edges is very likely - // to enter 'hash-mode' with many keys being added - // - // avoiding a for-in loop here as it leads to the function - // being de-optimised (at least in V8). From some simple - // benchmarks the performance is comparable, but allowing - // V8 to optimize may mean easy performance wins in the future. - - if (this._str) { - return this._str - } - - var str = this.final ? '1' : '0', - labels = Object.keys(this.edges).sort(), - len = labels.length - - for (var i = 0; i < len; i++) { - var label = labels[i], - node = this.edges[label] - - str = str + label + node.id - } - - return str -} - -/** - * Returns a new TokenSet that is the intersection of - * this TokenSet and the passed TokenSet. - * - * This intersection will take into account any wildcards - * contained within the TokenSet. - * - * @param {lunr.TokenSet} b - An other TokenSet to intersect with. - * @returns {lunr.TokenSet} - */ -lunr.TokenSet.prototype.intersect = function (b) { - var output = new lunr.TokenSet, - frame = undefined - - var stack = [{ - qNode: b, - output: output, - node: this - }] - - while (stack.length) { - frame = stack.pop() - - // NOTE: As with the #toString method, we are using - // Object.keys and a for loop instead of a for-in loop - // as both of these objects enter 'hash' mode, causing - // the function to be de-optimised in V8 - var qEdges = Object.keys(frame.qNode.edges), - qLen = qEdges.length, - nEdges = Object.keys(frame.node.edges), - nLen = nEdges.length - - for (var q = 0; q < qLen; q++) { - var qEdge = qEdges[q] - - for (var n = 0; n < nLen; n++) { - var nEdge = nEdges[n] - - if (nEdge == qEdge || qEdge == '*') { - var node = frame.node.edges[nEdge], - qNode = frame.qNode.edges[qEdge], - final = node.final && qNode.final, - next = undefined - - if (nEdge in frame.output.edges) { - // an edge already exists for this character - // no need to create a new node, just set the finality - // bit unless this node is already final - next = frame.output.edges[nEdge] - next.final = next.final || final - - } else { - // no edge exists yet, must create one - // set the finality bit and insert it - // into the output - next = new lunr.TokenSet - next.final = final - frame.output.edges[nEdge] = next - } - - stack.push({ - qNode: qNode, - output: next, - node: node - }) - } - } - } - } - - return output -} -lunr.TokenSet.Builder = function () { - this.previousWord = "" - this.root = new lunr.TokenSet - this.uncheckedNodes = [] - this.minimizedNodes = {} -} - -lunr.TokenSet.Builder.prototype.insert = function (word) { - var node, - commonPrefix = 0 - - if (word < this.previousWord) { - throw new Error ("Out of order word insertion") - } - - for (var i = 0; i < word.length && i < this.previousWord.length; i++) { - if (word[i] != this.previousWord[i]) break - commonPrefix++ - } - - this.minimize(commonPrefix) - - if (this.uncheckedNodes.length == 0) { - node = this.root - } else { - node = this.uncheckedNodes[this.uncheckedNodes.length - 1].child - } - - for (var i = commonPrefix; i < word.length; i++) { - var nextNode = new lunr.TokenSet, - char = word[i] - - node.edges[char] = nextNode - - this.uncheckedNodes.push({ - parent: node, - char: char, - child: nextNode - }) - - node = nextNode - } - - node.final = true - this.previousWord = word -} - -lunr.TokenSet.Builder.prototype.finish = function () { - this.minimize(0) -} - -lunr.TokenSet.Builder.prototype.minimize = function (downTo) { - for (var i = this.uncheckedNodes.length - 1; i >= downTo; i--) { - var node = this.uncheckedNodes[i], - childKey = node.child.toString() - - if (childKey in this.minimizedNodes) { - node.parent.edges[node.char] = this.minimizedNodes[childKey] - } else { - // Cache the key for this node since - // we know it can't change anymore - node.child._str = childKey - - this.minimizedNodes[childKey] = node.child - } - - this.uncheckedNodes.pop() - } -} -/*! - * lunr.Index - * Copyright (C) 2017 Oliver Nightingale - */ - -/** - * An index contains the built index of all documents and provides a query interface - * to the index. - * - * Usually instances of lunr.Index will not be created using this constructor, instead - * lunr.Builder should be used to construct new indexes, or lunr.Index.load should be - * used to load previously built and serialized indexes. - * - * @constructor - * @param {Object} attrs - The attributes of the built search index. - * @param {Object} attrs.invertedIndex - An index of term/field to document reference. - * @param {Object} attrs.documentVectors - Document vectors keyed by document reference. - * @param {lunr.TokenSet} attrs.tokenSet - An set of all corpus tokens. - * @param {string[]} attrs.fields - The names of indexed document fields. - * @param {lunr.Pipeline} attrs.pipeline - The pipeline to use for search terms. - */ -lunr.Index = function (attrs) { - this.invertedIndex = attrs.invertedIndex - this.fieldVectors = attrs.fieldVectors - this.tokenSet = attrs.tokenSet - this.fields = attrs.fields - this.pipeline = attrs.pipeline -} - -/** - * A result contains details of a document matching a search query. - * @typedef {Object} lunr.Index~Result - * @property {string} ref - The reference of the document this result represents. - * @property {number} score - A number between 0 and 1 representing how similar this document is to the query. - * @property {lunr.MatchData} matchData - Contains metadata about this match including which term(s) caused the match. - */ - -/** - * Although lunr provides the ability to create queries using lunr.Query, it also provides a simple - * query language which itself is parsed into an instance of lunr.Query. - * - * For programmatically building queries it is advised to directly use lunr.Query, the query language - * is best used for human entered text rather than program generated text. - * - * At its simplest queries can just be a single term, e.g. `hello`, multiple terms are also supported - * and will be combined with OR, e.g `hello world` will match documents that contain either 'hello' - * or 'world', though those that contain both will rank higher in the results. - * - * Wildcards can be included in terms to match one or more unspecified characters, these wildcards can - * be inserted anywhere within the term, and more than one wildcard can exist in a single term. Adding - * wildcards will increase the number of documents that will be found but can also have a negative - * impact on query performance, especially with wildcards at the beginning of a term. - * - * Terms can be restricted to specific fields, e.g. `title:hello`, only documents with the term - * hello in the title field will match this query. Using a field not present in the index will lead - * to an error being thrown. - * - * Modifiers can also be added to terms, lunr supports edit distance and boost modifiers on terms. A term - * boost will make documents matching that term score higher, e.g. `foo^5`. Edit distance is also supported - * to provide fuzzy matching, e.g. 'hello~2' will match documents with hello with an edit distance of 2. - * Avoid large values for edit distance to improve query performance. - * - * To escape special characters the backslash character '\' can be used, this allows searches to include - * characters that would normally be considered modifiers, e.g. `foo\~2` will search for a term "foo~2" instead - * of attempting to apply a boost of 2 to the search term "foo". - * - * @typedef {string} lunr.Index~QueryString - * @example Simple single term query - * hello - * @example Multiple term query - * hello world - * @example term scoped to a field - * title:hello - * @example term with a boost of 10 - * hello^10 - * @example term with an edit distance of 2 - * hello~2 - */ - -/** - * Performs a search against the index using lunr query syntax. - * - * Results will be returned sorted by their score, the most relevant results - * will be returned first. - * - * For more programmatic querying use lunr.Index#query. - * - * @param {lunr.Index~QueryString} queryString - A string containing a lunr query. - * @throws {lunr.QueryParseError} If the passed query string cannot be parsed. - * @returns {lunr.Index~Result[]} - */ -lunr.Index.prototype.search = function (queryString) { - return this.query(function (query) { - var parser = new lunr.QueryParser(queryString, query) - parser.parse() - }) -} - -/** - * A query builder callback provides a query object to be used to express - * the query to perform on the index. - * - * @callback lunr.Index~queryBuilder - * @param {lunr.Query} query - The query object to build up. - * @this lunr.Query - */ - -/** - * Performs a query against the index using the yielded lunr.Query object. - * - * If performing programmatic queries against the index, this method is preferred - * over lunr.Index#search so as to avoid the additional query parsing overhead. - * - * A query object is yielded to the supplied function which should be used to - * express the query to be run against the index. - * - * Note that although this function takes a callback parameter it is _not_ an - * asynchronous operation, the callback is just yielded a query object to be - * customized. - * - * @param {lunr.Index~queryBuilder} fn - A function that is used to build the query. - * @returns {lunr.Index~Result[]} - */ -lunr.Index.prototype.query = function (fn) { - // for each query clause - // * process terms - // * expand terms from token set - // * find matching documents and metadata - // * get document vectors - // * score documents - - var query = new lunr.Query(this.fields), - matchingFields = Object.create(null), - queryVectors = Object.create(null) - - fn.call(query, query) - - for (var i = 0; i < query.clauses.length; i++) { - /* - * Unless the pipeline has been disabled for this term, which is - * the case for terms with wildcards, we need to pass the clause - * term through the search pipeline. A pipeline returns an array - * of processed terms. Pipeline functions may expand the passed - * term, which means we may end up performing multiple index lookups - * for a single query term. - */ - var clause = query.clauses[i], - terms = null - - if (clause.usePipeline) { - terms = this.pipeline.runString(clause.term) - } else { - terms = [clause.term] - } - - for (var m = 0; m < terms.length; m++) { - var term = terms[m] - - /* - * Each term returned from the pipeline needs to use the same query - * clause object, e.g. the same boost and or edit distance. The - * simplest way to do this is to re-use the clause object but mutate - * its term property. - */ - clause.term = term - - /* - * From the term in the clause we create a token set which will then - * be used to intersect the indexes token set to get a list of terms - * to lookup in the inverted index - */ - var termTokenSet = lunr.TokenSet.fromClause(clause), - expandedTerms = this.tokenSet.intersect(termTokenSet).toArray() - - for (var j = 0; j < expandedTerms.length; j++) { - /* - * For each term get the posting and termIndex, this is required for - * building the query vector. - */ - var expandedTerm = expandedTerms[j], - posting = this.invertedIndex[expandedTerm], - termIndex = posting._index - - for (var k = 0; k < clause.fields.length; k++) { - /* - * For each field that this query term is scoped by (by default - * all fields are in scope) we need to get all the document refs - * that have this term in that field. - * - * The posting is the entry in the invertedIndex for the matching - * term from above. - */ - var field = clause.fields[k], - fieldPosting = posting[field], - matchingDocumentRefs = Object.keys(fieldPosting) - - /* - * To support field level boosts a query vector is created per - * field. This vector is populated using the termIndex found for - * the term and a unit value with the appropriate boost applied. - * - * If the query vector for this field does not exist yet it needs - * to be created. - */ - if (!(field in queryVectors)) { - queryVectors[field] = new lunr.Vector - } - - /* - * Using upsert because there could already be an entry in the vector - * for the term we are working with. In that case we just add the scores - * together. - */ - queryVectors[field].upsert(termIndex, 1 * clause.boost, function (a, b) { return a + b }) - - for (var l = 0; l < matchingDocumentRefs.length; l++) { - /* - * All metadata for this term/field/document triple - * are then extracted and collected into an instance - * of lunr.MatchData ready to be returned in the query - * results - */ - var matchingDocumentRef = matchingDocumentRefs[l], - matchingFieldRef = new lunr.FieldRef (matchingDocumentRef, field), - documentMetadata, matchData - - documentMetadata = fieldPosting[matchingDocumentRef] - matchData = new lunr.MatchData (expandedTerm, field, documentMetadata) - - if (matchingFieldRef in matchingFields) { - matchingFields[matchingFieldRef].combine(matchData) - } else { - matchingFields[matchingFieldRef] = matchData - } - - } - } - } - } - } - - var matchingFieldRefs = Object.keys(matchingFields), - results = {} - - for (var i = 0; i < matchingFieldRefs.length; i++) { - /* - * Currently we have document fields that match the query, but we - * need to return documents. The matchData and scores are combined - * from multiple fields belonging to the same document. - * - * Scores are calculated by field, using the query vectors created - * above, and combined into a final document score using addition. - */ - var fieldRef = lunr.FieldRef.fromString(matchingFieldRefs[i]), - docRef = fieldRef.docRef, - fieldVector = this.fieldVectors[fieldRef], - score = queryVectors[fieldRef.fieldName].similarity(fieldVector) - - if (docRef in results) { - results[docRef].score += score - results[docRef].matchData.combine(matchingFields[fieldRef]) - } else { - results[docRef] = { - ref: docRef, - score: score, - matchData: matchingFields[fieldRef] - } - } - } - - /* - * The results object needs to be converted into a list - * of results, sorted by score before being returned. - */ - return Object.keys(results) - .map(function (key) { - return results[key] - }) - .sort(function (a, b) { - return b.score - a.score - }) -} - -/** - * Prepares the index for JSON serialization. - * - * The schema for this JSON blob will be described in a - * separate JSON schema file. - * - * @returns {Object} - */ -lunr.Index.prototype.toJSON = function () { - var invertedIndex = Object.keys(this.invertedIndex) - .sort() - .map(function (term) { - return [term, this.invertedIndex[term]] - }, this) - - var fieldVectors = Object.keys(this.fieldVectors) - .map(function (ref) { - return [ref, this.fieldVectors[ref].toJSON()] - }, this) - - return { - version: lunr.version, - fields: this.fields, - fieldVectors: fieldVectors, - invertedIndex: invertedIndex, - pipeline: this.pipeline.toJSON() - } -} - -/** - * Loads a previously serialized lunr.Index - * - * @param {Object} serializedIndex - A previously serialized lunr.Index - * @returns {lunr.Index} - */ -lunr.Index.load = function (serializedIndex) { - var attrs = {}, - fieldVectors = {}, - serializedVectors = serializedIndex.fieldVectors, - invertedIndex = {}, - serializedInvertedIndex = serializedIndex.invertedIndex, - tokenSetBuilder = new lunr.TokenSet.Builder, - pipeline = lunr.Pipeline.load(serializedIndex.pipeline) - - if (serializedIndex.version != lunr.version) { - lunr.utils.warn("Version mismatch when loading serialised index. Current version of lunr '" + lunr.version + "' does not match serialized index '" + serializedIndex.version + "'") - } - - for (var i = 0; i < serializedVectors.length; i++) { - var tuple = serializedVectors[i], - ref = tuple[0], - elements = tuple[1] - - fieldVectors[ref] = new lunr.Vector(elements) - } - - for (var i = 0; i < serializedInvertedIndex.length; i++) { - var tuple = serializedInvertedIndex[i], - term = tuple[0], - posting = tuple[1] - - tokenSetBuilder.insert(term) - invertedIndex[term] = posting - } - - tokenSetBuilder.finish() - - attrs.fields = serializedIndex.fields - - attrs.fieldVectors = fieldVectors - attrs.invertedIndex = invertedIndex - attrs.tokenSet = tokenSetBuilder.root - attrs.pipeline = pipeline - - return new lunr.Index(attrs) -} -/*! - * lunr.Builder - * Copyright (C) 2017 Oliver Nightingale - */ - -/** - * lunr.Builder performs indexing on a set of documents and - * returns instances of lunr.Index ready for querying. - * - * All configuration of the index is done via the builder, the - * fields to index, the document reference, the text processing - * pipeline and document scoring parameters are all set on the - * builder before indexing. - * - * @constructor - * @property {string} _ref - Internal reference to the document reference field. - * @property {string[]} _fields - Internal reference to the document fields to index. - * @property {object} invertedIndex - The inverted index maps terms to document fields. - * @property {object} documentTermFrequencies - Keeps track of document term frequencies. - * @property {object} documentLengths - Keeps track of the length of documents added to the index. - * @property {lunr.tokenizer} tokenizer - Function for splitting strings into tokens for indexing. - * @property {lunr.Pipeline} pipeline - The pipeline performs text processing on tokens before indexing. - * @property {lunr.Pipeline} searchPipeline - A pipeline for processing search terms before querying the index. - * @property {number} documentCount - Keeps track of the total number of documents indexed. - * @property {number} _b - A parameter to control field length normalization, setting this to 0 disabled normalization, 1 fully normalizes field lengths, the default value is 0.75. - * @property {number} _k1 - A parameter to control how quickly an increase in term frequency results in term frequency saturation, the default value is 1.2. - * @property {number} termIndex - A counter incremented for each unique term, used to identify a terms position in the vector space. - * @property {array} metadataWhitelist - A list of metadata keys that have been whitelisted for entry in the index. - */ -lunr.Builder = function () { - this._ref = "id" - this._fields = [] - this.invertedIndex = Object.create(null) - this.fieldTermFrequencies = {} - this.fieldLengths = {} - this.tokenizer = lunr.tokenizer - this.pipeline = new lunr.Pipeline - this.searchPipeline = new lunr.Pipeline - this.documentCount = 0 - this._b = 0.75 - this._k1 = 1.2 - this.termIndex = 0 - this.metadataWhitelist = [] -} - -/** - * Sets the document field used as the document reference. Every document must have this field. - * The type of this field in the document should be a string, if it is not a string it will be - * coerced into a string by calling toString. - * - * The default ref is 'id'. - * - * The ref should _not_ be changed during indexing, it should be set before any documents are - * added to the index. Changing it during indexing can lead to inconsistent results. - * - * @param {string} ref - The name of the reference field in the document. - */ -lunr.Builder.prototype.ref = function (ref) { - this._ref = ref -} - -/** - * Adds a field to the list of document fields that will be indexed. Every document being - * indexed should have this field. Null values for this field in indexed documents will - * not cause errors but will limit the chance of that document being retrieved by searches. - * - * All fields should be added before adding documents to the index. Adding fields after - * a document has been indexed will have no effect on already indexed documents. - * - * @param {string} field - The name of a field to index in all documents. - */ -lunr.Builder.prototype.field = function (field) { - this._fields.push(field) -} - -/** - * A parameter to tune the amount of field length normalisation that is applied when - * calculating relevance scores. A value of 0 will completely disable any normalisation - * and a value of 1 will fully normalise field lengths. The default is 0.75. Values of b - * will be clamped to the range 0 - 1. - * - * @param {number} number - The value to set for this tuning parameter. - */ -lunr.Builder.prototype.b = function (number) { - if (number < 0) { - this._b = 0 - } else if (number > 1) { - this._b = 1 - } else { - this._b = number - } -} - -/** - * A parameter that controls the speed at which a rise in term frequency results in term - * frequency saturation. The default value is 1.2. Setting this to a higher value will give - * slower saturation levels, a lower value will result in quicker saturation. - * - * @param {number} number - The value to set for this tuning parameter. - */ -lunr.Builder.prototype.k1 = function (number) { - this._k1 = number -} - -/** - * Adds a document to the index. - * - * Before adding fields to the index the index should have been fully setup, with the document - * ref and all fields to index already having been specified. - * - * The document must have a field name as specified by the ref (by default this is 'id') and - * it should have all fields defined for indexing, though null or undefined values will not - * cause errors. - * - * @param {object} doc - The document to add to the index. - */ -lunr.Builder.prototype.add = function (doc) { - var docRef = doc[this._ref] - - this.documentCount += 1 - - for (var i = 0; i < this._fields.length; i++) { - var fieldName = this._fields[i], - field = doc[fieldName], - tokens = this.tokenizer(field), - terms = this.pipeline.run(tokens), - fieldRef = new lunr.FieldRef (docRef, fieldName), - fieldTerms = Object.create(null) - - this.fieldTermFrequencies[fieldRef] = fieldTerms - this.fieldLengths[fieldRef] = 0 - - // store the length of this field for this document - this.fieldLengths[fieldRef] += terms.length - - // calculate term frequencies for this field - for (var j = 0; j < terms.length; j++) { - var term = terms[j] - - if (fieldTerms[term] == undefined) { - fieldTerms[term] = 0 - } - - fieldTerms[term] += 1 - - // add to inverted index - // create an initial posting if one doesn't exist - if (this.invertedIndex[term] == undefined) { - var posting = Object.create(null) - posting["_index"] = this.termIndex - this.termIndex += 1 - - for (var k = 0; k < this._fields.length; k++) { - posting[this._fields[k]] = Object.create(null) - } - - this.invertedIndex[term] = posting - } - - // add an entry for this term/fieldName/docRef to the invertedIndex - if (this.invertedIndex[term][fieldName][docRef] == undefined) { - this.invertedIndex[term][fieldName][docRef] = Object.create(null) - } - - // store all whitelisted metadata about this token in the - // inverted index - for (var l = 0; l < this.metadataWhitelist.length; l++) { - var metadataKey = this.metadataWhitelist[l], - metadata = term.metadata[metadataKey] - - if (this.invertedIndex[term][fieldName][docRef][metadataKey] == undefined) { - this.invertedIndex[term][fieldName][docRef][metadataKey] = [] - } - - this.invertedIndex[term][fieldName][docRef][metadataKey].push(metadata) - } - } - - } -} - -/** - * Calculates the average document length for this index - * - * @private - */ -lunr.Builder.prototype.calculateAverageFieldLengths = function () { - - var fieldRefs = Object.keys(this.fieldLengths), - numberOfFields = fieldRefs.length, - accumulator = {}, - documentsWithField = {} - - for (var i = 0; i < numberOfFields; i++) { - var fieldRef = lunr.FieldRef.fromString(fieldRefs[i]), - field = fieldRef.fieldName - - documentsWithField[field] || (documentsWithField[field] = 0) - documentsWithField[field] += 1 - - accumulator[field] || (accumulator[field] = 0) - accumulator[field] += this.fieldLengths[fieldRef] - } - - for (var i = 0; i < this._fields.length; i++) { - var field = this._fields[i] - accumulator[field] = accumulator[field] / documentsWithField[field] - } - - this.averageFieldLength = accumulator -} - -/** - * Builds a vector space model of every document using lunr.Vector - * - * @private - */ -lunr.Builder.prototype.createFieldVectors = function () { - var fieldVectors = {}, - fieldRefs = Object.keys(this.fieldTermFrequencies), - fieldRefsLength = fieldRefs.length - - for (var i = 0; i < fieldRefsLength; i++) { - var fieldRef = lunr.FieldRef.fromString(fieldRefs[i]), - field = fieldRef.fieldName, - fieldLength = this.fieldLengths[fieldRef], - fieldVector = new lunr.Vector, - termFrequencies = this.fieldTermFrequencies[fieldRef], - terms = Object.keys(termFrequencies), - termsLength = terms.length - - for (var j = 0; j < termsLength; j++) { - var term = terms[j], - tf = termFrequencies[term], - termIndex = this.invertedIndex[term]._index, - idf = lunr.idf(this.invertedIndex[term], this.documentCount), - score = idf * ((this._k1 + 1) * tf) / (this._k1 * (1 - this._b + this._b * (fieldLength / this.averageFieldLength[field])) + tf), - scoreWithPrecision = Math.round(score * 1000) / 1000 - // Converts 1.23456789 to 1.234. - // Reducing the precision so that the vectors take up less - // space when serialised. Doing it now so that they behave - // the same before and after serialisation. Also, this is - // the fastest approach to reducing a number's precision in - // JavaScript. - - fieldVector.insert(termIndex, scoreWithPrecision) - } - - fieldVectors[fieldRef] = fieldVector - } - - this.fieldVectors = fieldVectors -} - -/** - * Creates a token set of all tokens in the index using lunr.TokenSet - * - * @private - */ -lunr.Builder.prototype.createTokenSet = function () { - this.tokenSet = lunr.TokenSet.fromArray( - Object.keys(this.invertedIndex).sort() - ) -} - -/** - * Builds the index, creating an instance of lunr.Index. - * - * This completes the indexing process and should only be called - * once all documents have been added to the index. - * - * @private - * @returns {lunr.Index} - */ -lunr.Builder.prototype.build = function () { - this.calculateAverageFieldLengths() - this.createFieldVectors() - this.createTokenSet() - - return new lunr.Index({ - invertedIndex: this.invertedIndex, - fieldVectors: this.fieldVectors, - tokenSet: this.tokenSet, - fields: this._fields, - pipeline: this.searchPipeline - }) -} - -/** - * Applies a plugin to the index builder. - * - * A plugin is a function that is called with the index builder as its context. - * Plugins can be used to customise or extend the behaviour of the index - * in some way. A plugin is just a function, that encapsulated the custom - * behaviour that should be applied when building the index. - * - * The plugin function will be called with the index builder as its argument, additional - * arguments can also be passed when calling use. The function will be called - * with the index builder as its context. - * - * @param {Function} plugin The plugin to apply. - */ -lunr.Builder.prototype.use = function (fn) { - var args = Array.prototype.slice.call(arguments, 1) - args.unshift(this) - fn.apply(this, args) -} -/** - * Contains and collects metadata about a matching document. - * A single instance of lunr.MatchData is returned as part of every - * lunr.Index~Result. - * - * @constructor - * @param {string} term - The term this match data is associated with - * @param {string} field - The field in which the term was found - * @param {object} metadata - The metadata recorded about this term in this field - * @property {object} metadata - A cloned collection of metadata associated with this document. - * @see {@link lunr.Index~Result} - */ -lunr.MatchData = function (term, field, metadata) { - var clonedMetadata = Object.create(null), - metadataKeys = Object.keys(metadata) - - // Cloning the metadata to prevent the original - // being mutated during match data combination. - // Metadata is kept in an array within the inverted - // index so cloning the data can be done with - // Array#slice - for (var i = 0; i < metadataKeys.length; i++) { - var key = metadataKeys[i] - clonedMetadata[key] = metadata[key].slice() - } - - this.metadata = Object.create(null) - this.metadata[term] = Object.create(null) - this.metadata[term][field] = clonedMetadata -} - -/** - * An instance of lunr.MatchData will be created for every term that matches a - * document. However only one instance is required in a lunr.Index~Result. This - * method combines metadata from another instance of lunr.MatchData with this - * objects metadata. - * - * @param {lunr.MatchData} otherMatchData - Another instance of match data to merge with this one. - * @see {@link lunr.Index~Result} - */ -lunr.MatchData.prototype.combine = function (otherMatchData) { - var terms = Object.keys(otherMatchData.metadata) - - for (var i = 0; i < terms.length; i++) { - var term = terms[i], - fields = Object.keys(otherMatchData.metadata[term]) - - if (this.metadata[term] == undefined) { - this.metadata[term] = Object.create(null) - } - - for (var j = 0; j < fields.length; j++) { - var field = fields[j], - keys = Object.keys(otherMatchData.metadata[term][field]) - - if (this.metadata[term][field] == undefined) { - this.metadata[term][field] = Object.create(null) - } - - for (var k = 0; k < keys.length; k++) { - var key = keys[k] - - if (this.metadata[term][field][key] == undefined) { - this.metadata[term][field][key] = otherMatchData.metadata[term][field][key] - } else { - this.metadata[term][field][key] = this.metadata[term][field][key].concat(otherMatchData.metadata[term][field][key]) - } - - } - } - } -} -/** - * A lunr.Query provides a programmatic way of defining queries to be performed - * against a {@link lunr.Index}. - * - * Prefer constructing a lunr.Query using the {@link lunr.Index#query} method - * so the query object is pre-initialized with the right index fields. - * - * @constructor - * @property {lunr.Query~Clause[]} clauses - An array of query clauses. - * @property {string[]} allFields - An array of all available fields in a lunr.Index. - */ -lunr.Query = function (allFields) { - this.clauses = [] - this.allFields = allFields -} - -/** - * Constants for indicating what kind of automatic wildcard insertion will be used when constructing a query clause. - * - * This allows wildcards to be added to the beginning and end of a term without having to manually do any string - * concatenation. - * - * The wildcard constants can be bitwise combined to select both leading and trailing wildcards. - * - * @constant - * @default - * @property {number} wildcard.NONE - The term will have no wildcards inserted, this is the default behaviour - * @property {number} wildcard.LEADING - Prepend the term with a wildcard, unless a leading wildcard already exists - * @property {number} wildcard.TRAILING - Append a wildcard to the term, unless a trailing wildcard already exists - * @see lunr.Query~Clause - * @see lunr.Query#clause - * @see lunr.Query#term - * @example query term with trailing wildcard - * query.term('foo', { wildcard: lunr.Query.wildcard.TRAILING }) - * @example query term with leading and trailing wildcard - * query.term('foo', { - * wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING - * }) - */ -lunr.Query.wildcard = new String ("*") -lunr.Query.wildcard.NONE = 0 -lunr.Query.wildcard.LEADING = 1 -lunr.Query.wildcard.TRAILING = 2 - -/** - * A single clause in a {@link lunr.Query} contains a term and details on how to - * match that term against a {@link lunr.Index}. - * - * @typedef {Object} lunr.Query~Clause - * @property {string[]} fields - The fields in an index this clause should be matched against. - * @property {number} [boost=1] - Any boost that should be applied when matching this clause. - * @property {number} [editDistance] - Whether the term should have fuzzy matching applied, and how fuzzy the match should be. - * @property {boolean} [usePipeline] - Whether the term should be passed through the search pipeline. - * @property {number} [wildcard=0] - Whether the term should have wildcards appended or prepended. - */ - -/** - * Adds a {@link lunr.Query~Clause} to this query. - * - * Unless the clause contains the fields to be matched all fields will be matched. In addition - * a default boost of 1 is applied to the clause. - * - * @param {lunr.Query~Clause} clause - The clause to add to this query. - * @see lunr.Query~Clause - * @returns {lunr.Query} - */ -lunr.Query.prototype.clause = function (clause) { - if (!('fields' in clause)) { - clause.fields = this.allFields - } - - if (!('boost' in clause)) { - clause.boost = 1 - } - - if (!('usePipeline' in clause)) { - clause.usePipeline = true - } - - if (!('wildcard' in clause)) { - clause.wildcard = lunr.Query.wildcard.NONE - } - - if ((clause.wildcard & lunr.Query.wildcard.LEADING) && (clause.term.charAt(0) != lunr.Query.wildcard)) { - clause.term = "*" + clause.term - } - - if ((clause.wildcard & lunr.Query.wildcard.TRAILING) && (clause.term.slice(-1) != lunr.Query.wildcard)) { - clause.term = "" + clause.term + "*" - } - - this.clauses.push(clause) - - return this -} - -/** - * Adds a term to the current query, under the covers this will create a {@link lunr.Query~Clause} - * to the list of clauses that make up this query. - * - * @param {string} term - The term to add to the query. - * @param {Object} [options] - Any additional properties to add to the query clause. - * @returns {lunr.Query} - * @see lunr.Query#clause - * @see lunr.Query~Clause - * @example adding a single term to a query - * query.term("foo") - * @example adding a single term to a query and specifying search fields, term boost and automatic trailing wildcard - * query.term("foo", { - * fields: ["title"], - * boost: 10, - * wildcard: lunr.Query.wildcard.TRAILING - * }) - */ -lunr.Query.prototype.term = function (term, options) { - var clause = options || {} - clause.term = term - - this.clause(clause) - - return this -} -lunr.QueryParseError = function (message, start, end) { - this.name = "QueryParseError" - this.message = message - this.start = start - this.end = end -} - -lunr.QueryParseError.prototype = new Error -lunr.QueryLexer = function (str) { - this.lexemes = [] - this.str = str - this.length = str.length - this.pos = 0 - this.start = 0 - this.escapeCharPositions = [] -} - -lunr.QueryLexer.prototype.run = function () { - var state = lunr.QueryLexer.lexText - - while (state) { - state = state(this) - } -} - -lunr.QueryLexer.prototype.sliceString = function () { - var subSlices = [], - sliceStart = this.start, - sliceEnd = this.pos - - for (var i = 0; i < this.escapeCharPositions.length; i++) { - sliceEnd = this.escapeCharPositions[i] - subSlices.push(this.str.slice(sliceStart, sliceEnd)) - sliceStart = sliceEnd + 1 - } - - subSlices.push(this.str.slice(sliceStart, this.pos)) - this.escapeCharPositions.length = 0 - - return subSlices.join('') -} - -lunr.QueryLexer.prototype.emit = function (type) { - this.lexemes.push({ - type: type, - str: this.sliceString(), - start: this.start, - end: this.pos - }) - - this.start = this.pos -} - -lunr.QueryLexer.prototype.escapeCharacter = function () { - this.escapeCharPositions.push(this.pos - 1) - this.pos += 1 -} - -lunr.QueryLexer.prototype.next = function () { - if (this.pos >= this.length) { - return lunr.QueryLexer.EOS - } - - var char = this.str.charAt(this.pos) - this.pos += 1 - return char -} - -lunr.QueryLexer.prototype.width = function () { - return this.pos - this.start -} - -lunr.QueryLexer.prototype.ignore = function () { - if (this.start == this.pos) { - this.pos += 1 - } - - this.start = this.pos -} - -lunr.QueryLexer.prototype.backup = function () { - this.pos -= 1 -} - -lunr.QueryLexer.prototype.acceptDigitRun = function () { - var char, charCode - - do { - char = this.next() - charCode = char.charCodeAt(0) - } while (charCode > 47 && charCode < 58) - - if (char != lunr.QueryLexer.EOS) { - this.backup() - } -} - -lunr.QueryLexer.prototype.more = function () { - return this.pos < this.length -} - -lunr.QueryLexer.EOS = 'EOS' -lunr.QueryLexer.FIELD = 'FIELD' -lunr.QueryLexer.TERM = 'TERM' -lunr.QueryLexer.EDIT_DISTANCE = 'EDIT_DISTANCE' -lunr.QueryLexer.BOOST = 'BOOST' - -lunr.QueryLexer.lexField = function (lexer) { - lexer.backup() - lexer.emit(lunr.QueryLexer.FIELD) - lexer.ignore() - return lunr.QueryLexer.lexText -} - -lunr.QueryLexer.lexTerm = function (lexer) { - if (lexer.width() > 1) { - lexer.backup() - lexer.emit(lunr.QueryLexer.TERM) - } - - lexer.ignore() - - if (lexer.more()) { - return lunr.QueryLexer.lexText - } -} - -lunr.QueryLexer.lexEditDistance = function (lexer) { - lexer.ignore() - lexer.acceptDigitRun() - lexer.emit(lunr.QueryLexer.EDIT_DISTANCE) - return lunr.QueryLexer.lexText -} - -lunr.QueryLexer.lexBoost = function (lexer) { - lexer.ignore() - lexer.acceptDigitRun() - lexer.emit(lunr.QueryLexer.BOOST) - return lunr.QueryLexer.lexText -} - -lunr.QueryLexer.lexEOS = function (lexer) { - if (lexer.width() > 0) { - lexer.emit(lunr.QueryLexer.TERM) - } -} - -// This matches the separator used when tokenising fields -// within a document. These should match otherwise it is -// not possible to search for some tokens within a document. -// -// It is possible for the user to change the separator on the -// tokenizer so it _might_ clash with any other of the special -// characters already used within the search string, e.g. :. -// -// This means that it is possible to change the separator in -// such a way that makes some words unsearchable using a search -// string. -lunr.QueryLexer.termSeparator = lunr.tokenizer.separator - -lunr.QueryLexer.lexText = function (lexer) { - while (true) { - var char = lexer.next() - - if (char == lunr.QueryLexer.EOS) { - return lunr.QueryLexer.lexEOS - } - - // Escape character is '\' - if (char.charCodeAt(0) == 92) { - lexer.escapeCharacter() - continue - } - - if (char == ":") { - return lunr.QueryLexer.lexField - } - - if (char == "~") { - lexer.backup() - if (lexer.width() > 0) { - lexer.emit(lunr.QueryLexer.TERM) - } - return lunr.QueryLexer.lexEditDistance - } - - if (char == "^") { - lexer.backup() - if (lexer.width() > 0) { - lexer.emit(lunr.QueryLexer.TERM) - } - return lunr.QueryLexer.lexBoost - } - - if (char.match(lunr.QueryLexer.termSeparator)) { - return lunr.QueryLexer.lexTerm - } - } -} - -lunr.QueryParser = function (str, query) { - this.lexer = new lunr.QueryLexer (str) - this.query = query - this.currentClause = {} - this.lexemeIdx = 0 -} - -lunr.QueryParser.prototype.parse = function () { - this.lexer.run() - this.lexemes = this.lexer.lexemes - - var state = lunr.QueryParser.parseFieldOrTerm - - while (state) { - state = state(this) - } - - return this.query -} - -lunr.QueryParser.prototype.peekLexeme = function () { - return this.lexemes[this.lexemeIdx] -} - -lunr.QueryParser.prototype.consumeLexeme = function () { - var lexeme = this.peekLexeme() - this.lexemeIdx += 1 - return lexeme -} - -lunr.QueryParser.prototype.nextClause = function () { - var completedClause = this.currentClause - this.query.clause(completedClause) - this.currentClause = {} -} - -lunr.QueryParser.parseFieldOrTerm = function (parser) { - var lexeme = parser.peekLexeme() - - if (lexeme == undefined) { - return - } - - switch (lexeme.type) { - case lunr.QueryLexer.FIELD: - return lunr.QueryParser.parseField - case lunr.QueryLexer.TERM: - return lunr.QueryParser.parseTerm - default: - var errorMessage = "expected either a field or a term, found " + lexeme.type - - if (lexeme.str.length >= 1) { - errorMessage += " with value '" + lexeme.str + "'" - } - - throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) - } -} - -lunr.QueryParser.parseField = function (parser) { - var lexeme = parser.consumeLexeme() - - if (lexeme == undefined) { - return - } - - if (parser.query.allFields.indexOf(lexeme.str) == -1) { - var possibleFields = parser.query.allFields.map(function (f) { return "'" + f + "'" }).join(', '), - errorMessage = "unrecognised field '" + lexeme.str + "', possible fields: " + possibleFields - - throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) - } - - parser.currentClause.fields = [lexeme.str] - - var nextLexeme = parser.peekLexeme() - - if (nextLexeme == undefined) { - var errorMessage = "expecting term, found nothing" - throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) - } - - switch (nextLexeme.type) { - case lunr.QueryLexer.TERM: - return lunr.QueryParser.parseTerm - default: - var errorMessage = "expecting term, found '" + nextLexeme.type + "'" - throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) - } -} - -lunr.QueryParser.parseTerm = function (parser) { - var lexeme = parser.consumeLexeme() - - if (lexeme == undefined) { - return - } - - parser.currentClause.term = lexeme.str.toLowerCase() - - if (lexeme.str.indexOf("*") != -1) { - parser.currentClause.usePipeline = false - } - - var nextLexeme = parser.peekLexeme() - - if (nextLexeme == undefined) { - parser.nextClause() - return - } - - switch (nextLexeme.type) { - case lunr.QueryLexer.TERM: - parser.nextClause() - return lunr.QueryParser.parseTerm - case lunr.QueryLexer.FIELD: - parser.nextClause() - return lunr.QueryParser.parseField - case lunr.QueryLexer.EDIT_DISTANCE: - return lunr.QueryParser.parseEditDistance - case lunr.QueryLexer.BOOST: - return lunr.QueryParser.parseBoost - default: - var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'" - throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) - } -} - -lunr.QueryParser.parseEditDistance = function (parser) { - var lexeme = parser.consumeLexeme() - - if (lexeme == undefined) { - return - } - - var editDistance = parseInt(lexeme.str, 10) - - if (isNaN(editDistance)) { - var errorMessage = "edit distance must be numeric" - throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) - } - - parser.currentClause.editDistance = editDistance - - var nextLexeme = parser.peekLexeme() - - if (nextLexeme == undefined) { - parser.nextClause() - return - } - - switch (nextLexeme.type) { - case lunr.QueryLexer.TERM: - parser.nextClause() - return lunr.QueryParser.parseTerm - case lunr.QueryLexer.FIELD: - parser.nextClause() - return lunr.QueryParser.parseField - case lunr.QueryLexer.EDIT_DISTANCE: - return lunr.QueryParser.parseEditDistance - case lunr.QueryLexer.BOOST: - return lunr.QueryParser.parseBoost - default: - var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'" - throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) - } -} - -lunr.QueryParser.parseBoost = function (parser) { - var lexeme = parser.consumeLexeme() - - if (lexeme == undefined) { - return - } - - var boost = parseInt(lexeme.str, 10) - - if (isNaN(boost)) { - var errorMessage = "boost must be numeric" - throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) - } - - parser.currentClause.boost = boost - - var nextLexeme = parser.peekLexeme() - - if (nextLexeme == undefined) { - parser.nextClause() - return - } - - switch (nextLexeme.type) { - case lunr.QueryLexer.TERM: - parser.nextClause() - return lunr.QueryParser.parseTerm - case lunr.QueryLexer.FIELD: - parser.nextClause() - return lunr.QueryParser.parseField - case lunr.QueryLexer.EDIT_DISTANCE: - return lunr.QueryParser.parseEditDistance - case lunr.QueryLexer.BOOST: - return lunr.QueryParser.parseBoost - default: - var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'" - throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) - } -} - - /** - * export the module via AMD, CommonJS or as a browser global - * Export code from https://github.com/umdjs/umd/blob/master/returnExports.js - */ - ;(function (root, factory) { - if (typeof define === 'function' && define.amd) { - // AMD. Register as an anonymous module. - define(factory) - } else if (typeof exports === 'object') { - /** - * Node. Does not work with strict CommonJS, but - * only CommonJS-like enviroments that support module.exports, - * like Node. - */ - module.exports = factory() - } else { - // Browser globals (root is window) - root.lunr = factory() - } - }(this, function () { - /** - * Just return a value to define the module export. - * This example returns an object, but the module - * can return a function as the exported value. - */ - return lunr - })) -})(); +/** + * lunr - http://lunrjs.com - A bit like Solr, but much smaller and not as bright - 2.1.2 + * Copyright (C) 2017 Oliver Nightingale + * @license MIT + */ + +;(function(){ + +/** + * A convenience function for configuring and constructing + * a new lunr Index. + * + * A lunr.Builder instance is created and the pipeline setup + * with a trimmer, stop word filter and stemmer. + * + * This builder object is yielded to the configuration function + * that is passed as a parameter, allowing the list of fields + * and other builder parameters to be customised. + * + * All documents _must_ be added within the passed config function. + * + * @example + * var idx = lunr(function () { + * this.field('title') + * this.field('body') + * this.ref('id') + * + * documents.forEach(function (doc) { + * this.add(doc) + * }, this) + * }) + * + * @see {@link lunr.Builder} + * @see {@link lunr.Pipeline} + * @see {@link lunr.trimmer} + * @see {@link lunr.stopWordFilter} + * @see {@link lunr.stemmer} + * @namespace {function} lunr + */ +var lunr = function (config) { + var builder = new lunr.Builder + + builder.pipeline.add( + lunr.trimmer, + lunr.stopWordFilter, + lunr.stemmer + ) + + builder.searchPipeline.add( + lunr.stemmer + ) + + config.call(builder, builder) + return builder.build() +} + +lunr.version = "2.1.2" +/*! + * lunr.utils + * Copyright (C) 2017 Oliver Nightingale + */ + +/** + * A namespace containing utils for the rest of the lunr library + */ +lunr.utils = {} + +/** + * Print a warning message to the console. + * + * @param {String} message The message to be printed. + * @memberOf Utils + */ +lunr.utils.warn = (function (global) { + /* eslint-disable no-console */ + return function (message) { + if (global.console && console.warn) { + console.warn(message) + } + } + /* eslint-enable no-console */ +})(this) + +/** + * Convert an object to a string. + * + * In the case of `null` and `undefined` the function returns + * the empty string, in all other cases the result of calling + * `toString` on the passed object is returned. + * + * @param {Any} obj The object to convert to a string. + * @return {String} string representation of the passed object. + * @memberOf Utils + */ +lunr.utils.asString = function (obj) { + if (obj === void 0 || obj === null) { + return "" + } else { + return obj.toString() + } +} +lunr.FieldRef = function (docRef, fieldName) { + this.docRef = docRef + this.fieldName = fieldName + this._stringValue = fieldName + lunr.FieldRef.joiner + docRef +} + +lunr.FieldRef.joiner = "/" + +lunr.FieldRef.fromString = function (s) { + var n = s.indexOf(lunr.FieldRef.joiner) + + if (n === -1) { + throw "malformed field ref string" + } + + var fieldRef = s.slice(0, n), + docRef = s.slice(n + 1) + + return new lunr.FieldRef (docRef, fieldRef) +} + +lunr.FieldRef.prototype.toString = function () { + return this._stringValue +} +/** + * A function to calculate the inverse document frequency for + * a posting. This is shared between the builder and the index + * + * @private + * @param {object} posting - The posting for a given term + * @param {number} documentCount - The total number of documents. + */ +lunr.idf = function (posting, documentCount) { + var documentsWithTerm = 0 + + for (var fieldName in posting) { + if (fieldName == '_index') continue // Ignore the term index, its not a field + documentsWithTerm += Object.keys(posting[fieldName]).length + } + + var x = (documentCount - documentsWithTerm + 0.5) / (documentsWithTerm + 0.5) + + return Math.log(1 + Math.abs(x)) +} + +/** + * A token wraps a string representation of a token + * as it is passed through the text processing pipeline. + * + * @constructor + * @param {string} [str=''] - The string token being wrapped. + * @param {object} [metadata={}] - Metadata associated with this token. + */ +lunr.Token = function (str, metadata) { + this.str = str || "" + this.metadata = metadata || {} +} + +/** + * Returns the token string that is being wrapped by this object. + * + * @returns {string} + */ +lunr.Token.prototype.toString = function () { + return this.str +} + +/** + * A token update function is used when updating or optionally + * when cloning a token. + * + * @callback lunr.Token~updateFunction + * @param {string} str - The string representation of the token. + * @param {Object} metadata - All metadata associated with this token. + */ + +/** + * Applies the given function to the wrapped string token. + * + * @example + * token.update(function (str, metadata) { + * return str.toUpperCase() + * }) + * + * @param {lunr.Token~updateFunction} fn - A function to apply to the token string. + * @returns {lunr.Token} + */ +lunr.Token.prototype.update = function (fn) { + this.str = fn(this.str, this.metadata) + return this +} + +/** + * Creates a clone of this token. Optionally a function can be + * applied to the cloned token. + * + * @param {lunr.Token~updateFunction} [fn] - An optional function to apply to the cloned token. + * @returns {lunr.Token} + */ +lunr.Token.prototype.clone = function (fn) { + fn = fn || function (s) { return s } + return new lunr.Token (fn(this.str, this.metadata), this.metadata) +} +/*! + * lunr.tokenizer + * Copyright (C) 2017 Oliver Nightingale + */ + +/** + * A function for splitting a string into tokens ready to be inserted into + * the search index. Uses `lunr.tokenizer.separator` to split strings, change + * the value of this property to change how strings are split into tokens. + * + * This tokenizer will convert its parameter to a string by calling `toString` and + * then will split this string on the character in `lunr.tokenizer.separator`. + * Arrays will have their elements converted to strings and wrapped in a lunr.Token. + * + * @static + * @param {?(string|object|object[])} obj - The object to convert into tokens + * @returns {lunr.Token[]} + */ +lunr.tokenizer = function (obj) { + if (obj == null || obj == undefined) { + return [] + } + + if (Array.isArray(obj)) { + return obj.map(function (t) { + return new lunr.Token(lunr.utils.asString(t).toLowerCase()) + }) + } + + var str = obj.toString().trim().toLowerCase(), + len = str.length, + tokens = [] + + for (var sliceEnd = 0, sliceStart = 0; sliceEnd <= len; sliceEnd++) { + var char = str.charAt(sliceEnd), + sliceLength = sliceEnd - sliceStart + + if ((char.match(lunr.tokenizer.separator) || sliceEnd == len)) { + + if (sliceLength > 0) { + tokens.push( + new lunr.Token (str.slice(sliceStart, sliceEnd), { + position: [sliceStart, sliceLength], + index: tokens.length + }) + ) + } + + sliceStart = sliceEnd + 1 + } + + } + + return tokens +} + +/** + * The separator used to split a string into tokens. Override this property to change the behaviour of + * `lunr.tokenizer` behaviour when tokenizing strings. By default this splits on whitespace and hyphens. + * + * @static + * @see lunr.tokenizer + */ +lunr.tokenizer.separator = /[\s\-]+/ +/*! + * lunr.Pipeline + * Copyright (C) 2017 Oliver Nightingale + */ + +/** + * lunr.Pipelines maintain an ordered list of functions to be applied to all + * tokens in documents entering the search index and queries being ran against + * the index. + * + * An instance of lunr.Index created with the lunr shortcut will contain a + * pipeline with a stop word filter and an English language stemmer. Extra + * functions can be added before or after either of these functions or these + * default functions can be removed. + * + * When run the pipeline will call each function in turn, passing a token, the + * index of that token in the original list of all tokens and finally a list of + * all the original tokens. + * + * The output of functions in the pipeline will be passed to the next function + * in the pipeline. To exclude a token from entering the index the function + * should return undefined, the rest of the pipeline will not be called with + * this token. + * + * For serialisation of pipelines to work, all functions used in an instance of + * a pipeline should be registered with lunr.Pipeline. Registered functions can + * then be loaded. If trying to load a serialised pipeline that uses functions + * that are not registered an error will be thrown. + * + * If not planning on serialising the pipeline then registering pipeline functions + * is not necessary. + * + * @constructor + */ +lunr.Pipeline = function () { + this._stack = [] +} + +lunr.Pipeline.registeredFunctions = Object.create(null) + +/** + * A pipeline function maps lunr.Token to lunr.Token. A lunr.Token contains the token + * string as well as all known metadata. A pipeline function can mutate the token string + * or mutate (or add) metadata for a given token. + * + * A pipeline function can indicate that the passed token should be discarded by returning + * null. This token will not be passed to any downstream pipeline functions and will not be + * added to the index. + * + * Multiple tokens can be returned by returning an array of tokens. Each token will be passed + * to any downstream pipeline functions and all will returned tokens will be added to the index. + * + * Any number of pipeline functions may be chained together using a lunr.Pipeline. + * + * @interface lunr.PipelineFunction + * @param {lunr.Token} token - A token from the document being processed. + * @param {number} i - The index of this token in the complete list of tokens for this document/field. + * @param {lunr.Token[]} tokens - All tokens for this document/field. + * @returns {(?lunr.Token|lunr.Token[])} + */ + +/** + * Register a function with the pipeline. + * + * Functions that are used in the pipeline should be registered if the pipeline + * needs to be serialised, or a serialised pipeline needs to be loaded. + * + * Registering a function does not add it to a pipeline, functions must still be + * added to instances of the pipeline for them to be used when running a pipeline. + * + * @param {lunr.PipelineFunction} fn - The function to check for. + * @param {String} label - The label to register this function with + */ +lunr.Pipeline.registerFunction = function (fn, label) { + if (label in this.registeredFunctions) { + lunr.utils.warn('Overwriting existing registered function: ' + label) + } + + fn.label = label + lunr.Pipeline.registeredFunctions[fn.label] = fn +} + +/** + * Warns if the function is not registered as a Pipeline function. + * + * @param {lunr.PipelineFunction} fn - The function to check for. + * @private + */ +lunr.Pipeline.warnIfFunctionNotRegistered = function (fn) { + var isRegistered = fn.label && (fn.label in this.registeredFunctions) + + if (!isRegistered) { + lunr.utils.warn('Function is not registered with pipeline. This may cause problems when serialising the index.\n', fn) + } +} + +/** + * Loads a previously serialised pipeline. + * + * All functions to be loaded must already be registered with lunr.Pipeline. + * If any function from the serialised data has not been registered then an + * error will be thrown. + * + * @param {Object} serialised - The serialised pipeline to load. + * @returns {lunr.Pipeline} + */ +lunr.Pipeline.load = function (serialised) { + var pipeline = new lunr.Pipeline + + serialised.forEach(function (fnName) { + var fn = lunr.Pipeline.registeredFunctions[fnName] + + if (fn) { + pipeline.add(fn) + } else { + throw new Error('Cannot load unregistered function: ' + fnName) + } + }) + + return pipeline +} + +/** + * Adds new functions to the end of the pipeline. + * + * Logs a warning if the function has not been registered. + * + * @param {lunr.PipelineFunction[]} functions - Any number of functions to add to the pipeline. + */ +lunr.Pipeline.prototype.add = function () { + var fns = Array.prototype.slice.call(arguments) + + fns.forEach(function (fn) { + lunr.Pipeline.warnIfFunctionNotRegistered(fn) + this._stack.push(fn) + }, this) +} + +/** + * Adds a single function after a function that already exists in the + * pipeline. + * + * Logs a warning if the function has not been registered. + * + * @param {lunr.PipelineFunction} existingFn - A function that already exists in the pipeline. + * @param {lunr.PipelineFunction} newFn - The new function to add to the pipeline. + */ +lunr.Pipeline.prototype.after = function (existingFn, newFn) { + lunr.Pipeline.warnIfFunctionNotRegistered(newFn) + + var pos = this._stack.indexOf(existingFn) + if (pos == -1) { + throw new Error('Cannot find existingFn') + } + + pos = pos + 1 + this._stack.splice(pos, 0, newFn) +} + +/** + * Adds a single function before a function that already exists in the + * pipeline. + * + * Logs a warning if the function has not been registered. + * + * @param {lunr.PipelineFunction} existingFn - A function that already exists in the pipeline. + * @param {lunr.PipelineFunction} newFn - The new function to add to the pipeline. + */ +lunr.Pipeline.prototype.before = function (existingFn, newFn) { + lunr.Pipeline.warnIfFunctionNotRegistered(newFn) + + var pos = this._stack.indexOf(existingFn) + if (pos == -1) { + throw new Error('Cannot find existingFn') + } + + this._stack.splice(pos, 0, newFn) +} + +/** + * Removes a function from the pipeline. + * + * @param {lunr.PipelineFunction} fn The function to remove from the pipeline. + */ +lunr.Pipeline.prototype.remove = function (fn) { + var pos = this._stack.indexOf(fn) + if (pos == -1) { + return + } + + this._stack.splice(pos, 1) +} + +/** + * Runs the current list of functions that make up the pipeline against the + * passed tokens. + * + * @param {Array} tokens The tokens to run through the pipeline. + * @returns {Array} + */ +lunr.Pipeline.prototype.run = function (tokens) { + var stackLength = this._stack.length + + for (var i = 0; i < stackLength; i++) { + var fn = this._stack[i] + + tokens = tokens.reduce(function (memo, token, j) { + var result = fn(token, j, tokens) + + if (result === void 0 || result === '') return memo + + return memo.concat(result) + }, []) + } + + return tokens +} + +/** + * Convenience method for passing a string through a pipeline and getting + * strings out. This method takes care of wrapping the passed string in a + * token and mapping the resulting tokens back to strings. + * + * @param {string} str - The string to pass through the pipeline. + * @returns {string[]} + */ +lunr.Pipeline.prototype.runString = function (str) { + var token = new lunr.Token (str) + + return this.run([token]).map(function (t) { + return t.toString() + }) +} + +/** + * Resets the pipeline by removing any existing processors. + * + */ +lunr.Pipeline.prototype.reset = function () { + this._stack = [] +} + +/** + * Returns a representation of the pipeline ready for serialisation. + * + * Logs a warning if the function has not been registered. + * + * @returns {Array} + */ +lunr.Pipeline.prototype.toJSON = function () { + return this._stack.map(function (fn) { + lunr.Pipeline.warnIfFunctionNotRegistered(fn) + + return fn.label + }) +} +/*! + * lunr.Vector + * Copyright (C) 2017 Oliver Nightingale + */ + +/** + * A vector is used to construct the vector space of documents and queries. These + * vectors support operations to determine the similarity between two documents or + * a document and a query. + * + * Normally no parameters are required for initializing a vector, but in the case of + * loading a previously dumped vector the raw elements can be provided to the constructor. + * + * For performance reasons vectors are implemented with a flat array, where an elements + * index is immediately followed by its value. E.g. [index, value, index, value]. This + * allows the underlying array to be as sparse as possible and still offer decent + * performance when being used for vector calculations. + * + * @constructor + * @param {Number[]} [elements] - The flat list of element index and element value pairs. + */ +lunr.Vector = function (elements) { + this._magnitude = 0 + this.elements = elements || [] +} + + +/** + * Calculates the position within the vector to insert a given index. + * + * This is used internally by insert and upsert. If there are duplicate indexes then + * the position is returned as if the value for that index were to be updated, but it + * is the callers responsibility to check whether there is a duplicate at that index + * + * @param {Number} insertIdx - The index at which the element should be inserted. + * @returns {Number} + */ +lunr.Vector.prototype.positionForIndex = function (index) { + // For an empty vector the tuple can be inserted at the beginning + if (this.elements.length == 0) { + return 0 + } + + var start = 0, + end = this.elements.length / 2, + sliceLength = end - start, + pivotPoint = Math.floor(sliceLength / 2), + pivotIndex = this.elements[pivotPoint * 2] + + while (sliceLength > 1) { + if (pivotIndex < index) { + start = pivotPoint + } + + if (pivotIndex > index) { + end = pivotPoint + } + + if (pivotIndex == index) { + break + } + + sliceLength = end - start + pivotPoint = start + Math.floor(sliceLength / 2) + pivotIndex = this.elements[pivotPoint * 2] + } + + if (pivotIndex == index) { + return pivotPoint * 2 + } + + if (pivotIndex > index) { + return pivotPoint * 2 + } + + if (pivotIndex < index) { + return (pivotPoint + 1) * 2 + } +} + +/** + * Inserts an element at an index within the vector. + * + * Does not allow duplicates, will throw an error if there is already an entry + * for this index. + * + * @param {Number} insertIdx - The index at which the element should be inserted. + * @param {Number} val - The value to be inserted into the vector. + */ +lunr.Vector.prototype.insert = function (insertIdx, val) { + this.upsert(insertIdx, val, function () { + throw "duplicate index" + }) +} + +/** + * Inserts or updates an existing index within the vector. + * + * @param {Number} insertIdx - The index at which the element should be inserted. + * @param {Number} val - The value to be inserted into the vector. + * @param {function} fn - A function that is called for updates, the existing value and the + * requested value are passed as arguments + */ +lunr.Vector.prototype.upsert = function (insertIdx, val, fn) { + this._magnitude = 0 + var position = this.positionForIndex(insertIdx) + + if (this.elements[position] == insertIdx) { + this.elements[position + 1] = fn(this.elements[position + 1], val) + } else { + this.elements.splice(position, 0, insertIdx, val) + } +} + +/** + * Calculates the magnitude of this vector. + * + * @returns {Number} + */ +lunr.Vector.prototype.magnitude = function () { + if (this._magnitude) return this._magnitude + + var sumOfSquares = 0, + elementsLength = this.elements.length + + for (var i = 1; i < elementsLength; i += 2) { + var val = this.elements[i] + sumOfSquares += val * val + } + + return this._magnitude = Math.sqrt(sumOfSquares) +} + +/** + * Calculates the dot product of this vector and another vector. + * + * @param {lunr.Vector} otherVector - The vector to compute the dot product with. + * @returns {Number} + */ +lunr.Vector.prototype.dot = function (otherVector) { + var dotProduct = 0, + a = this.elements, b = otherVector.elements, + aLen = a.length, bLen = b.length, + aVal = 0, bVal = 0, + i = 0, j = 0 + + while (i < aLen && j < bLen) { + aVal = a[i], bVal = b[j] + if (aVal < bVal) { + i += 2 + } else if (aVal > bVal) { + j += 2 + } else if (aVal == bVal) { + dotProduct += a[i + 1] * b[j + 1] + i += 2 + j += 2 + } + } + + return dotProduct +} + +/** + * Calculates the cosine similarity between this vector and another + * vector. + * + * @param {lunr.Vector} otherVector - The other vector to calculate the + * similarity with. + * @returns {Number} + */ +lunr.Vector.prototype.similarity = function (otherVector) { + return this.dot(otherVector) / (this.magnitude() * otherVector.magnitude()) +} + +/** + * Converts the vector to an array of the elements within the vector. + * + * @returns {Number[]} + */ +lunr.Vector.prototype.toArray = function () { + var output = new Array (this.elements.length / 2) + + for (var i = 1, j = 0; i < this.elements.length; i += 2, j++) { + output[j] = this.elements[i] + } + + return output +} + +/** + * A JSON serializable representation of the vector. + * + * @returns {Number[]} + */ +lunr.Vector.prototype.toJSON = function () { + return this.elements +} +/* eslint-disable */ +/*! + * lunr.stemmer + * Copyright (C) 2017 Oliver Nightingale + * Includes code from - http://tartarus.org/~martin/PorterStemmer/js.txt + */ + +/** + * lunr.stemmer is an english language stemmer, this is a JavaScript + * implementation of the PorterStemmer taken from http://tartarus.org/~martin + * + * @static + * @implements {lunr.PipelineFunction} + * @param {lunr.Token} token - The string to stem + * @returns {lunr.Token} + * @see {@link lunr.Pipeline} + */ +lunr.stemmer = (function(){ + var step2list = { + "ational" : "ate", + "tional" : "tion", + "enci" : "ence", + "anci" : "ance", + "izer" : "ize", + "bli" : "ble", + "alli" : "al", + "entli" : "ent", + "eli" : "e", + "ousli" : "ous", + "ization" : "ize", + "ation" : "ate", + "ator" : "ate", + "alism" : "al", + "iveness" : "ive", + "fulness" : "ful", + "ousness" : "ous", + "aliti" : "al", + "iviti" : "ive", + "biliti" : "ble", + "logi" : "log" + }, + + step3list = { + "icate" : "ic", + "ative" : "", + "alize" : "al", + "iciti" : "ic", + "ical" : "ic", + "ful" : "", + "ness" : "" + }, + + c = "[^aeiou]", // consonant + v = "[aeiouy]", // vowel + C = c + "[^aeiouy]*", // consonant sequence + V = v + "[aeiou]*", // vowel sequence + + mgr0 = "^(" + C + ")?" + V + C, // [C]VC... is m>0 + meq1 = "^(" + C + ")?" + V + C + "(" + V + ")?$", // [C]VC[V] is m=1 + mgr1 = "^(" + C + ")?" + V + C + V + C, // [C]VCVC... is m>1 + s_v = "^(" + C + ")?" + v; // vowel in stem + + var re_mgr0 = new RegExp(mgr0); + var re_mgr1 = new RegExp(mgr1); + var re_meq1 = new RegExp(meq1); + var re_s_v = new RegExp(s_v); + + var re_1a = /^(.+?)(ss|i)es$/; + var re2_1a = /^(.+?)([^s])s$/; + var re_1b = /^(.+?)eed$/; + var re2_1b = /^(.+?)(ed|ing)$/; + var re_1b_2 = /.$/; + var re2_1b_2 = /(at|bl|iz)$/; + var re3_1b_2 = new RegExp("([^aeiouylsz])\\1$"); + var re4_1b_2 = new RegExp("^" + C + v + "[^aeiouwxy]$"); + + var re_1c = /^(.+?[^aeiou])y$/; + var re_2 = /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/; + + var re_3 = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/; + + var re_4 = /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/; + var re2_4 = /^(.+?)(s|t)(ion)$/; + + var re_5 = /^(.+?)e$/; + var re_5_1 = /ll$/; + var re3_5 = new RegExp("^" + C + v + "[^aeiouwxy]$"); + + var porterStemmer = function porterStemmer(w) { + var stem, + suffix, + firstch, + re, + re2, + re3, + re4; + + if (w.length < 3) { return w; } + + firstch = w.substr(0,1); + if (firstch == "y") { + w = firstch.toUpperCase() + w.substr(1); + } + + // Step 1a + re = re_1a + re2 = re2_1a; + + if (re.test(w)) { w = w.replace(re,"$1$2"); } + else if (re2.test(w)) { w = w.replace(re2,"$1$2"); } + + // Step 1b + re = re_1b; + re2 = re2_1b; + if (re.test(w)) { + var fp = re.exec(w); + re = re_mgr0; + if (re.test(fp[1])) { + re = re_1b_2; + w = w.replace(re,""); + } + } else if (re2.test(w)) { + var fp = re2.exec(w); + stem = fp[1]; + re2 = re_s_v; + if (re2.test(stem)) { + w = stem; + re2 = re2_1b_2; + re3 = re3_1b_2; + re4 = re4_1b_2; + if (re2.test(w)) { w = w + "e"; } + else if (re3.test(w)) { re = re_1b_2; w = w.replace(re,""); } + else if (re4.test(w)) { w = w + "e"; } + } + } + + // Step 1c - replace suffix y or Y by i if preceded by a non-vowel which is not the first letter of the word (so cry -> cri, by -> by, say -> say) + re = re_1c; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + w = stem + "i"; + } + + // Step 2 + re = re_2; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + suffix = fp[2]; + re = re_mgr0; + if (re.test(stem)) { + w = stem + step2list[suffix]; + } + } + + // Step 3 + re = re_3; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + suffix = fp[2]; + re = re_mgr0; + if (re.test(stem)) { + w = stem + step3list[suffix]; + } + } + + // Step 4 + re = re_4; + re2 = re2_4; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + re = re_mgr1; + if (re.test(stem)) { + w = stem; + } + } else if (re2.test(w)) { + var fp = re2.exec(w); + stem = fp[1] + fp[2]; + re2 = re_mgr1; + if (re2.test(stem)) { + w = stem; + } + } + + // Step 5 + re = re_5; + if (re.test(w)) { + var fp = re.exec(w); + stem = fp[1]; + re = re_mgr1; + re2 = re_meq1; + re3 = re3_5; + if (re.test(stem) || (re2.test(stem) && !(re3.test(stem)))) { + w = stem; + } + } + + re = re_5_1; + re2 = re_mgr1; + if (re.test(w) && re2.test(w)) { + re = re_1b_2; + w = w.replace(re,""); + } + + // and turn initial Y back to y + + if (firstch == "y") { + w = firstch.toLowerCase() + w.substr(1); + } + + return w; + }; + + return function (token) { + return token.update(porterStemmer); + } +})(); + +lunr.Pipeline.registerFunction(lunr.stemmer, 'stemmer') +/*! + * lunr.stopWordFilter + * Copyright (C) 2017 Oliver Nightingale + */ + +/** + * lunr.generateStopWordFilter builds a stopWordFilter function from the provided + * list of stop words. + * + * The built in lunr.stopWordFilter is built using this generator and can be used + * to generate custom stopWordFilters for applications or non English languages. + * + * @param {Array} token The token to pass through the filter + * @returns {lunr.PipelineFunction} + * @see lunr.Pipeline + * @see lunr.stopWordFilter + */ +lunr.generateStopWordFilter = function (stopWords) { + var words = stopWords.reduce(function (memo, stopWord) { + memo[stopWord] = stopWord + return memo + }, {}) + + return function (token) { + if (token && words[token.toString()] !== token.toString()) return token + } +} + +/** + * lunr.stopWordFilter is an English language stop word list filter, any words + * contained in the list will not be passed through the filter. + * + * This is intended to be used in the Pipeline. If the token does not pass the + * filter then undefined will be returned. + * + * @implements {lunr.PipelineFunction} + * @params {lunr.Token} token - A token to check for being a stop word. + * @returns {lunr.Token} + * @see {@link lunr.Pipeline} + */ +lunr.stopWordFilter = lunr.generateStopWordFilter([ + 'a', + 'able', + 'about', + 'across', + 'after', + 'all', + 'almost', + 'also', + 'am', + 'among', + 'an', + 'and', + 'any', + 'are', + 'as', + 'at', + 'be', + 'because', + 'been', + 'but', + 'by', + 'can', + 'cannot', + 'could', + 'dear', + 'did', + 'do', + 'does', + 'either', + 'else', + 'ever', + 'every', + 'for', + 'from', + 'get', + 'got', + 'had', + 'has', + 'have', + 'he', + 'her', + 'hers', + 'him', + 'his', + 'how', + 'however', + 'i', + 'if', + 'in', + 'into', + 'is', + 'it', + 'its', + 'just', + 'least', + 'let', + 'like', + 'likely', + 'may', + 'me', + 'might', + 'most', + 'must', + 'my', + 'neither', + 'no', + 'nor', + 'not', + 'of', + 'off', + 'often', + 'on', + 'only', + 'or', + 'other', + 'our', + 'own', + 'rather', + 'said', + 'say', + 'says', + 'she', + 'should', + 'since', + 'so', + 'some', + 'than', + 'that', + 'the', + 'their', + 'them', + 'then', + 'there', + 'these', + 'they', + 'this', + 'tis', + 'to', + 'too', + 'twas', + 'us', + 'wants', + 'was', + 'we', + 'were', + 'what', + 'when', + 'where', + 'which', + 'while', + 'who', + 'whom', + 'why', + 'will', + 'with', + 'would', + 'yet', + 'you', + 'your' +]) + +lunr.Pipeline.registerFunction(lunr.stopWordFilter, 'stopWordFilter') +/*! + * lunr.trimmer + * Copyright (C) 2017 Oliver Nightingale + */ + +/** + * lunr.trimmer is a pipeline function for trimming non word + * characters from the beginning and end of tokens before they + * enter the index. + * + * This implementation may not work correctly for non latin + * characters and should either be removed or adapted for use + * with languages with non-latin characters. + * + * @static + * @implements {lunr.PipelineFunction} + * @param {lunr.Token} token The token to pass through the filter + * @returns {lunr.Token} + * @see lunr.Pipeline + */ +lunr.trimmer = function (token) { + return token.update(function (s) { + return s.replace(/^\W+/, '').replace(/\W+$/, '') + }) +} + +lunr.Pipeline.registerFunction(lunr.trimmer, 'trimmer') +/*! + * lunr.TokenSet + * Copyright (C) 2017 Oliver Nightingale + */ + +/** + * A token set is used to store the unique list of all tokens + * within an index. Token sets are also used to represent an + * incoming query to the index, this query token set and index + * token set are then intersected to find which tokens to look + * up in the inverted index. + * + * A token set can hold multiple tokens, as in the case of the + * index token set, or it can hold a single token as in the + * case of a simple query token set. + * + * Additionally token sets are used to perform wildcard matching. + * Leading, contained and trailing wildcards are supported, and + * from this edit distance matching can also be provided. + * + * Token sets are implemented as a minimal finite state automata, + * where both common prefixes and suffixes are shared between tokens. + * This helps to reduce the space used for storing the token set. + * + * @constructor + */ +lunr.TokenSet = function () { + this.final = false + this.edges = {} + this.id = lunr.TokenSet._nextId + lunr.TokenSet._nextId += 1 +} + +/** + * Keeps track of the next, auto increment, identifier to assign + * to a new tokenSet. + * + * TokenSets require a unique identifier to be correctly minimised. + * + * @private + */ +lunr.TokenSet._nextId = 1 + +/** + * Creates a TokenSet instance from the given sorted array of words. + * + * @param {String[]} arr - A sorted array of strings to create the set from. + * @returns {lunr.TokenSet} + * @throws Will throw an error if the input array is not sorted. + */ +lunr.TokenSet.fromArray = function (arr) { + var builder = new lunr.TokenSet.Builder + + for (var i = 0, len = arr.length; i < len; i++) { + builder.insert(arr[i]) + } + + builder.finish() + return builder.root +} + +/** + * Creates a token set from a query clause. + * + * @private + * @param {Object} clause - A single clause from lunr.Query. + * @param {string} clause.term - The query clause term. + * @param {number} [clause.editDistance] - The optional edit distance for the term. + * @returns {lunr.TokenSet} + */ +lunr.TokenSet.fromClause = function (clause) { + if ('editDistance' in clause) { + return lunr.TokenSet.fromFuzzyString(clause.term, clause.editDistance) + } else { + return lunr.TokenSet.fromString(clause.term) + } +} + +/** + * Creates a token set representing a single string with a specified + * edit distance. + * + * Insertions, deletions, substitutions and transpositions are each + * treated as an edit distance of 1. + * + * Increasing the allowed edit distance will have a dramatic impact + * on the performance of both creating and intersecting these TokenSets. + * It is advised to keep the edit distance less than 3. + * + * @param {string} str - The string to create the token set from. + * @param {number} editDistance - The allowed edit distance to match. + * @returns {lunr.Vector} + */ +lunr.TokenSet.fromFuzzyString = function (str, editDistance) { + var root = new lunr.TokenSet + + var stack = [{ + node: root, + editsRemaining: editDistance, + str: str + }] + + while (stack.length) { + var frame = stack.pop() + + // no edit + if (frame.str.length > 0) { + var char = frame.str.charAt(0), + noEditNode + + if (char in frame.node.edges) { + noEditNode = frame.node.edges[char] + } else { + noEditNode = new lunr.TokenSet + frame.node.edges[char] = noEditNode + } + + if (frame.str.length == 1) { + noEditNode.final = true + } else { + stack.push({ + node: noEditNode, + editsRemaining: frame.editsRemaining, + str: frame.str.slice(1) + }) + } + } + + // deletion + // can only do a deletion if we have enough edits remaining + // and if there are characters left to delete in the string + if (frame.editsRemaining > 0 && frame.str.length > 1) { + var char = frame.str.charAt(1), + deletionNode + + if (char in frame.node.edges) { + deletionNode = frame.node.edges[char] + } else { + deletionNode = new lunr.TokenSet + frame.node.edges[char] = deletionNode + } + + if (frame.str.length <= 2) { + deletionNode.final = true + } else { + stack.push({ + node: deletionNode, + editsRemaining: frame.editsRemaining - 1, + str: frame.str.slice(2) + }) + } + } + + // deletion + // just removing the last character from the str + if (frame.editsRemaining > 0 && frame.str.length == 1) { + frame.node.final = true + } + + // substitution + // can only do a substitution if we have enough edits remaining + // and if there are characters left to substitute + if (frame.editsRemaining > 0 && frame.str.length >= 1) { + if ("*" in frame.node.edges) { + var substitutionNode = frame.node.edges["*"] + } else { + var substitutionNode = new lunr.TokenSet + frame.node.edges["*"] = substitutionNode + } + + if (frame.str.length == 1) { + substitutionNode.final = true + } else { + stack.push({ + node: substitutionNode, + editsRemaining: frame.editsRemaining - 1, + str: frame.str.slice(1) + }) + } + } + + // insertion + // can only do insertion if there are edits remaining + if (frame.editsRemaining > 0) { + if ("*" in frame.node.edges) { + var insertionNode = frame.node.edges["*"] + } else { + var insertionNode = new lunr.TokenSet + frame.node.edges["*"] = insertionNode + } + + if (frame.str.length == 0) { + insertionNode.final = true + } else { + stack.push({ + node: insertionNode, + editsRemaining: frame.editsRemaining - 1, + str: frame.str + }) + } + } + + // transposition + // can only do a transposition if there are edits remaining + // and there are enough characters to transpose + if (frame.editsRemaining > 0 && frame.str.length > 1) { + var charA = frame.str.charAt(0), + charB = frame.str.charAt(1), + transposeNode + + if (charB in frame.node.edges) { + transposeNode = frame.node.edges[charB] + } else { + transposeNode = new lunr.TokenSet + frame.node.edges[charB] = transposeNode + } + + if (frame.str.length == 1) { + transposeNode.final = true + } else { + stack.push({ + node: transposeNode, + editsRemaining: frame.editsRemaining - 1, + str: charA + frame.str.slice(2) + }) + } + } + } + + return root +} + +/** + * Creates a TokenSet from a string. + * + * The string may contain one or more wildcard characters (*) + * that will allow wildcard matching when intersecting with + * another TokenSet. + * + * @param {string} str - The string to create a TokenSet from. + * @returns {lunr.TokenSet} + */ +lunr.TokenSet.fromString = function (str) { + var node = new lunr.TokenSet, + root = node, + wildcardFound = false + + /* + * Iterates through all characters within the passed string + * appending a node for each character. + * + * As soon as a wildcard character is found then a self + * referencing edge is introduced to continually match + * any number of any characters. + */ + for (var i = 0, len = str.length; i < len; i++) { + var char = str[i], + final = (i == len - 1) + + if (char == "*") { + wildcardFound = true + node.edges[char] = node + node.final = final + + } else { + var next = new lunr.TokenSet + next.final = final + + node.edges[char] = next + node = next + + // TODO: is this needed anymore? + if (wildcardFound) { + node.edges["*"] = root + } + } + } + + return root +} + +/** + * Converts this TokenSet into an array of strings + * contained within the TokenSet. + * + * @returns {string[]} + */ +lunr.TokenSet.prototype.toArray = function () { + var words = [] + + var stack = [{ + prefix: "", + node: this + }] + + while (stack.length) { + var frame = stack.pop(), + edges = Object.keys(frame.node.edges), + len = edges.length + + if (frame.node.final) { + words.push(frame.prefix) + } + + for (var i = 0; i < len; i++) { + var edge = edges[i] + + stack.push({ + prefix: frame.prefix.concat(edge), + node: frame.node.edges[edge] + }) + } + } + + return words +} + +/** + * Generates a string representation of a TokenSet. + * + * This is intended to allow TokenSets to be used as keys + * in objects, largely to aid the construction and minimisation + * of a TokenSet. As such it is not designed to be a human + * friendly representation of the TokenSet. + * + * @returns {string} + */ +lunr.TokenSet.prototype.toString = function () { + // NOTE: Using Object.keys here as this.edges is very likely + // to enter 'hash-mode' with many keys being added + // + // avoiding a for-in loop here as it leads to the function + // being de-optimised (at least in V8). From some simple + // benchmarks the performance is comparable, but allowing + // V8 to optimize may mean easy performance wins in the future. + + if (this._str) { + return this._str + } + + var str = this.final ? '1' : '0', + labels = Object.keys(this.edges).sort(), + len = labels.length + + for (var i = 0; i < len; i++) { + var label = labels[i], + node = this.edges[label] + + str = str + label + node.id + } + + return str +} + +/** + * Returns a new TokenSet that is the intersection of + * this TokenSet and the passed TokenSet. + * + * This intersection will take into account any wildcards + * contained within the TokenSet. + * + * @param {lunr.TokenSet} b - An other TokenSet to intersect with. + * @returns {lunr.TokenSet} + */ +lunr.TokenSet.prototype.intersect = function (b) { + var output = new lunr.TokenSet, + frame = undefined + + var stack = [{ + qNode: b, + output: output, + node: this + }] + + while (stack.length) { + frame = stack.pop() + + // NOTE: As with the #toString method, we are using + // Object.keys and a for loop instead of a for-in loop + // as both of these objects enter 'hash' mode, causing + // the function to be de-optimised in V8 + var qEdges = Object.keys(frame.qNode.edges), + qLen = qEdges.length, + nEdges = Object.keys(frame.node.edges), + nLen = nEdges.length + + for (var q = 0; q < qLen; q++) { + var qEdge = qEdges[q] + + for (var n = 0; n < nLen; n++) { + var nEdge = nEdges[n] + + if (nEdge == qEdge || qEdge == '*') { + var node = frame.node.edges[nEdge], + qNode = frame.qNode.edges[qEdge], + final = node.final && qNode.final, + next = undefined + + if (nEdge in frame.output.edges) { + // an edge already exists for this character + // no need to create a new node, just set the finality + // bit unless this node is already final + next = frame.output.edges[nEdge] + next.final = next.final || final + + } else { + // no edge exists yet, must create one + // set the finality bit and insert it + // into the output + next = new lunr.TokenSet + next.final = final + frame.output.edges[nEdge] = next + } + + stack.push({ + qNode: qNode, + output: next, + node: node + }) + } + } + } + } + + return output +} +lunr.TokenSet.Builder = function () { + this.previousWord = "" + this.root = new lunr.TokenSet + this.uncheckedNodes = [] + this.minimizedNodes = {} +} + +lunr.TokenSet.Builder.prototype.insert = function (word) { + var node, + commonPrefix = 0 + + if (word < this.previousWord) { + throw new Error ("Out of order word insertion") + } + + for (var i = 0; i < word.length && i < this.previousWord.length; i++) { + if (word[i] != this.previousWord[i]) break + commonPrefix++ + } + + this.minimize(commonPrefix) + + if (this.uncheckedNodes.length == 0) { + node = this.root + } else { + node = this.uncheckedNodes[this.uncheckedNodes.length - 1].child + } + + for (var i = commonPrefix; i < word.length; i++) { + var nextNode = new lunr.TokenSet, + char = word[i] + + node.edges[char] = nextNode + + this.uncheckedNodes.push({ + parent: node, + char: char, + child: nextNode + }) + + node = nextNode + } + + node.final = true + this.previousWord = word +} + +lunr.TokenSet.Builder.prototype.finish = function () { + this.minimize(0) +} + +lunr.TokenSet.Builder.prototype.minimize = function (downTo) { + for (var i = this.uncheckedNodes.length - 1; i >= downTo; i--) { + var node = this.uncheckedNodes[i], + childKey = node.child.toString() + + if (childKey in this.minimizedNodes) { + node.parent.edges[node.char] = this.minimizedNodes[childKey] + } else { + // Cache the key for this node since + // we know it can't change anymore + node.child._str = childKey + + this.minimizedNodes[childKey] = node.child + } + + this.uncheckedNodes.pop() + } +} +/*! + * lunr.Index + * Copyright (C) 2017 Oliver Nightingale + */ + +/** + * An index contains the built index of all documents and provides a query interface + * to the index. + * + * Usually instances of lunr.Index will not be created using this constructor, instead + * lunr.Builder should be used to construct new indexes, or lunr.Index.load should be + * used to load previously built and serialized indexes. + * + * @constructor + * @param {Object} attrs - The attributes of the built search index. + * @param {Object} attrs.invertedIndex - An index of term/field to document reference. + * @param {Object} attrs.documentVectors - Document vectors keyed by document reference. + * @param {lunr.TokenSet} attrs.tokenSet - An set of all corpus tokens. + * @param {string[]} attrs.fields - The names of indexed document fields. + * @param {lunr.Pipeline} attrs.pipeline - The pipeline to use for search terms. + */ +lunr.Index = function (attrs) { + this.invertedIndex = attrs.invertedIndex + this.fieldVectors = attrs.fieldVectors + this.tokenSet = attrs.tokenSet + this.fields = attrs.fields + this.pipeline = attrs.pipeline +} + +/** + * A result contains details of a document matching a search query. + * @typedef {Object} lunr.Index~Result + * @property {string} ref - The reference of the document this result represents. + * @property {number} score - A number between 0 and 1 representing how similar this document is to the query. + * @property {lunr.MatchData} matchData - Contains metadata about this match including which term(s) caused the match. + */ + +/** + * Although lunr provides the ability to create queries using lunr.Query, it also provides a simple + * query language which itself is parsed into an instance of lunr.Query. + * + * For programmatically building queries it is advised to directly use lunr.Query, the query language + * is best used for human entered text rather than program generated text. + * + * At its simplest queries can just be a single term, e.g. `hello`, multiple terms are also supported + * and will be combined with OR, e.g `hello world` will match documents that contain either 'hello' + * or 'world', though those that contain both will rank higher in the results. + * + * Wildcards can be included in terms to match one or more unspecified characters, these wildcards can + * be inserted anywhere within the term, and more than one wildcard can exist in a single term. Adding + * wildcards will increase the number of documents that will be found but can also have a negative + * impact on query performance, especially with wildcards at the beginning of a term. + * + * Terms can be restricted to specific fields, e.g. `title:hello`, only documents with the term + * hello in the title field will match this query. Using a field not present in the index will lead + * to an error being thrown. + * + * Modifiers can also be added to terms, lunr supports edit distance and boost modifiers on terms. A term + * boost will make documents matching that term score higher, e.g. `foo^5`. Edit distance is also supported + * to provide fuzzy matching, e.g. 'hello~2' will match documents with hello with an edit distance of 2. + * Avoid large values for edit distance to improve query performance. + * + * To escape special characters the backslash character '\' can be used, this allows searches to include + * characters that would normally be considered modifiers, e.g. `foo\~2` will search for a term "foo~2" instead + * of attempting to apply a boost of 2 to the search term "foo". + * + * @typedef {string} lunr.Index~QueryString + * @example Simple single term query + * hello + * @example Multiple term query + * hello world + * @example term scoped to a field + * title:hello + * @example term with a boost of 10 + * hello^10 + * @example term with an edit distance of 2 + * hello~2 + */ + +/** + * Performs a search against the index using lunr query syntax. + * + * Results will be returned sorted by their score, the most relevant results + * will be returned first. + * + * For more programmatic querying use lunr.Index#query. + * + * @param {lunr.Index~QueryString} queryString - A string containing a lunr query. + * @throws {lunr.QueryParseError} If the passed query string cannot be parsed. + * @returns {lunr.Index~Result[]} + */ +lunr.Index.prototype.search = function (queryString) { + return this.query(function (query) { + var parser = new lunr.QueryParser(queryString, query) + parser.parse() + }) +} + +/** + * A query builder callback provides a query object to be used to express + * the query to perform on the index. + * + * @callback lunr.Index~queryBuilder + * @param {lunr.Query} query - The query object to build up. + * @this lunr.Query + */ + +/** + * Performs a query against the index using the yielded lunr.Query object. + * + * If performing programmatic queries against the index, this method is preferred + * over lunr.Index#search so as to avoid the additional query parsing overhead. + * + * A query object is yielded to the supplied function which should be used to + * express the query to be run against the index. + * + * Note that although this function takes a callback parameter it is _not_ an + * asynchronous operation, the callback is just yielded a query object to be + * customized. + * + * @param {lunr.Index~queryBuilder} fn - A function that is used to build the query. + * @returns {lunr.Index~Result[]} + */ +lunr.Index.prototype.query = function (fn) { + // for each query clause + // * process terms + // * expand terms from token set + // * find matching documents and metadata + // * get document vectors + // * score documents + + var query = new lunr.Query(this.fields), + matchingFields = Object.create(null), + queryVectors = Object.create(null) + + fn.call(query, query) + + for (var i = 0; i < query.clauses.length; i++) { + /* + * Unless the pipeline has been disabled for this term, which is + * the case for terms with wildcards, we need to pass the clause + * term through the search pipeline. A pipeline returns an array + * of processed terms. Pipeline functions may expand the passed + * term, which means we may end up performing multiple index lookups + * for a single query term. + */ + var clause = query.clauses[i], + terms = null + + if (clause.usePipeline) { + terms = this.pipeline.runString(clause.term) + } else { + terms = [clause.term] + } + + for (var m = 0; m < terms.length; m++) { + var term = terms[m] + + /* + * Each term returned from the pipeline needs to use the same query + * clause object, e.g. the same boost and or edit distance. The + * simplest way to do this is to re-use the clause object but mutate + * its term property. + */ + clause.term = term + + /* + * From the term in the clause we create a token set which will then + * be used to intersect the indexes token set to get a list of terms + * to lookup in the inverted index + */ + var termTokenSet = lunr.TokenSet.fromClause(clause), + expandedTerms = this.tokenSet.intersect(termTokenSet).toArray() + + for (var j = 0; j < expandedTerms.length; j++) { + /* + * For each term get the posting and termIndex, this is required for + * building the query vector. + */ + var expandedTerm = expandedTerms[j], + posting = this.invertedIndex[expandedTerm], + termIndex = posting._index + + for (var k = 0; k < clause.fields.length; k++) { + /* + * For each field that this query term is scoped by (by default + * all fields are in scope) we need to get all the document refs + * that have this term in that field. + * + * The posting is the entry in the invertedIndex for the matching + * term from above. + */ + var field = clause.fields[k], + fieldPosting = posting[field], + matchingDocumentRefs = Object.keys(fieldPosting) + + /* + * To support field level boosts a query vector is created per + * field. This vector is populated using the termIndex found for + * the term and a unit value with the appropriate boost applied. + * + * If the query vector for this field does not exist yet it needs + * to be created. + */ + if (!(field in queryVectors)) { + queryVectors[field] = new lunr.Vector + } + + /* + * Using upsert because there could already be an entry in the vector + * for the term we are working with. In that case we just add the scores + * together. + */ + queryVectors[field].upsert(termIndex, 1 * clause.boost, function (a, b) { return a + b }) + + for (var l = 0; l < matchingDocumentRefs.length; l++) { + /* + * All metadata for this term/field/document triple + * are then extracted and collected into an instance + * of lunr.MatchData ready to be returned in the query + * results + */ + var matchingDocumentRef = matchingDocumentRefs[l], + matchingFieldRef = new lunr.FieldRef (matchingDocumentRef, field), + documentMetadata, matchData + + documentMetadata = fieldPosting[matchingDocumentRef] + matchData = new lunr.MatchData (expandedTerm, field, documentMetadata) + + if (matchingFieldRef in matchingFields) { + matchingFields[matchingFieldRef].combine(matchData) + } else { + matchingFields[matchingFieldRef] = matchData + } + + } + } + } + } + } + + var matchingFieldRefs = Object.keys(matchingFields), + results = {} + + for (var i = 0; i < matchingFieldRefs.length; i++) { + /* + * Currently we have document fields that match the query, but we + * need to return documents. The matchData and scores are combined + * from multiple fields belonging to the same document. + * + * Scores are calculated by field, using the query vectors created + * above, and combined into a final document score using addition. + */ + var fieldRef = lunr.FieldRef.fromString(matchingFieldRefs[i]), + docRef = fieldRef.docRef, + fieldVector = this.fieldVectors[fieldRef], + score = queryVectors[fieldRef.fieldName].similarity(fieldVector) + + if (docRef in results) { + results[docRef].score += score + results[docRef].matchData.combine(matchingFields[fieldRef]) + } else { + results[docRef] = { + ref: docRef, + score: score, + matchData: matchingFields[fieldRef] + } + } + } + + /* + * The results object needs to be converted into a list + * of results, sorted by score before being returned. + */ + return Object.keys(results) + .map(function (key) { + return results[key] + }) + .sort(function (a, b) { + return b.score - a.score + }) +} + +/** + * Prepares the index for JSON serialization. + * + * The schema for this JSON blob will be described in a + * separate JSON schema file. + * + * @returns {Object} + */ +lunr.Index.prototype.toJSON = function () { + var invertedIndex = Object.keys(this.invertedIndex) + .sort() + .map(function (term) { + return [term, this.invertedIndex[term]] + }, this) + + var fieldVectors = Object.keys(this.fieldVectors) + .map(function (ref) { + return [ref, this.fieldVectors[ref].toJSON()] + }, this) + + return { + version: lunr.version, + fields: this.fields, + fieldVectors: fieldVectors, + invertedIndex: invertedIndex, + pipeline: this.pipeline.toJSON() + } +} + +/** + * Loads a previously serialized lunr.Index + * + * @param {Object} serializedIndex - A previously serialized lunr.Index + * @returns {lunr.Index} + */ +lunr.Index.load = function (serializedIndex) { + var attrs = {}, + fieldVectors = {}, + serializedVectors = serializedIndex.fieldVectors, + invertedIndex = {}, + serializedInvertedIndex = serializedIndex.invertedIndex, + tokenSetBuilder = new lunr.TokenSet.Builder, + pipeline = lunr.Pipeline.load(serializedIndex.pipeline) + + if (serializedIndex.version != lunr.version) { + lunr.utils.warn("Version mismatch when loading serialised index. Current version of lunr '" + lunr.version + "' does not match serialized index '" + serializedIndex.version + "'") + } + + for (var i = 0; i < serializedVectors.length; i++) { + var tuple = serializedVectors[i], + ref = tuple[0], + elements = tuple[1] + + fieldVectors[ref] = new lunr.Vector(elements) + } + + for (var i = 0; i < serializedInvertedIndex.length; i++) { + var tuple = serializedInvertedIndex[i], + term = tuple[0], + posting = tuple[1] + + tokenSetBuilder.insert(term) + invertedIndex[term] = posting + } + + tokenSetBuilder.finish() + + attrs.fields = serializedIndex.fields + + attrs.fieldVectors = fieldVectors + attrs.invertedIndex = invertedIndex + attrs.tokenSet = tokenSetBuilder.root + attrs.pipeline = pipeline + + return new lunr.Index(attrs) +} +/*! + * lunr.Builder + * Copyright (C) 2017 Oliver Nightingale + */ + +/** + * lunr.Builder performs indexing on a set of documents and + * returns instances of lunr.Index ready for querying. + * + * All configuration of the index is done via the builder, the + * fields to index, the document reference, the text processing + * pipeline and document scoring parameters are all set on the + * builder before indexing. + * + * @constructor + * @property {string} _ref - Internal reference to the document reference field. + * @property {string[]} _fields - Internal reference to the document fields to index. + * @property {object} invertedIndex - The inverted index maps terms to document fields. + * @property {object} documentTermFrequencies - Keeps track of document term frequencies. + * @property {object} documentLengths - Keeps track of the length of documents added to the index. + * @property {lunr.tokenizer} tokenizer - Function for splitting strings into tokens for indexing. + * @property {lunr.Pipeline} pipeline - The pipeline performs text processing on tokens before indexing. + * @property {lunr.Pipeline} searchPipeline - A pipeline for processing search terms before querying the index. + * @property {number} documentCount - Keeps track of the total number of documents indexed. + * @property {number} _b - A parameter to control field length normalization, setting this to 0 disabled normalization, 1 fully normalizes field lengths, the default value is 0.75. + * @property {number} _k1 - A parameter to control how quickly an increase in term frequency results in term frequency saturation, the default value is 1.2. + * @property {number} termIndex - A counter incremented for each unique term, used to identify a terms position in the vector space. + * @property {array} metadataWhitelist - A list of metadata keys that have been whitelisted for entry in the index. + */ +lunr.Builder = function () { + this._ref = "id" + this._fields = [] + this.invertedIndex = Object.create(null) + this.fieldTermFrequencies = {} + this.fieldLengths = {} + this.tokenizer = lunr.tokenizer + this.pipeline = new lunr.Pipeline + this.searchPipeline = new lunr.Pipeline + this.documentCount = 0 + this._b = 0.75 + this._k1 = 1.2 + this.termIndex = 0 + this.metadataWhitelist = [] +} + +/** + * Sets the document field used as the document reference. Every document must have this field. + * The type of this field in the document should be a string, if it is not a string it will be + * coerced into a string by calling toString. + * + * The default ref is 'id'. + * + * The ref should _not_ be changed during indexing, it should be set before any documents are + * added to the index. Changing it during indexing can lead to inconsistent results. + * + * @param {string} ref - The name of the reference field in the document. + */ +lunr.Builder.prototype.ref = function (ref) { + this._ref = ref +} + +/** + * Adds a field to the list of document fields that will be indexed. Every document being + * indexed should have this field. Null values for this field in indexed documents will + * not cause errors but will limit the chance of that document being retrieved by searches. + * + * All fields should be added before adding documents to the index. Adding fields after + * a document has been indexed will have no effect on already indexed documents. + * + * @param {string} field - The name of a field to index in all documents. + */ +lunr.Builder.prototype.field = function (field) { + this._fields.push(field) +} + +/** + * A parameter to tune the amount of field length normalisation that is applied when + * calculating relevance scores. A value of 0 will completely disable any normalisation + * and a value of 1 will fully normalise field lengths. The default is 0.75. Values of b + * will be clamped to the range 0 - 1. + * + * @param {number} number - The value to set for this tuning parameter. + */ +lunr.Builder.prototype.b = function (number) { + if (number < 0) { + this._b = 0 + } else if (number > 1) { + this._b = 1 + } else { + this._b = number + } +} + +/** + * A parameter that controls the speed at which a rise in term frequency results in term + * frequency saturation. The default value is 1.2. Setting this to a higher value will give + * slower saturation levels, a lower value will result in quicker saturation. + * + * @param {number} number - The value to set for this tuning parameter. + */ +lunr.Builder.prototype.k1 = function (number) { + this._k1 = number +} + +/** + * Adds a document to the index. + * + * Before adding fields to the index the index should have been fully setup, with the document + * ref and all fields to index already having been specified. + * + * The document must have a field name as specified by the ref (by default this is 'id') and + * it should have all fields defined for indexing, though null or undefined values will not + * cause errors. + * + * @param {object} doc - The document to add to the index. + */ +lunr.Builder.prototype.add = function (doc) { + var docRef = doc[this._ref] + + this.documentCount += 1 + + for (var i = 0; i < this._fields.length; i++) { + var fieldName = this._fields[i], + field = doc[fieldName], + tokens = this.tokenizer(field), + terms = this.pipeline.run(tokens), + fieldRef = new lunr.FieldRef (docRef, fieldName), + fieldTerms = Object.create(null) + + this.fieldTermFrequencies[fieldRef] = fieldTerms + this.fieldLengths[fieldRef] = 0 + + // store the length of this field for this document + this.fieldLengths[fieldRef] += terms.length + + // calculate term frequencies for this field + for (var j = 0; j < terms.length; j++) { + var term = terms[j] + + if (fieldTerms[term] == undefined) { + fieldTerms[term] = 0 + } + + fieldTerms[term] += 1 + + // add to inverted index + // create an initial posting if one doesn't exist + if (this.invertedIndex[term] == undefined) { + var posting = Object.create(null) + posting["_index"] = this.termIndex + this.termIndex += 1 + + for (var k = 0; k < this._fields.length; k++) { + posting[this._fields[k]] = Object.create(null) + } + + this.invertedIndex[term] = posting + } + + // add an entry for this term/fieldName/docRef to the invertedIndex + if (this.invertedIndex[term][fieldName][docRef] == undefined) { + this.invertedIndex[term][fieldName][docRef] = Object.create(null) + } + + // store all whitelisted metadata about this token in the + // inverted index + for (var l = 0; l < this.metadataWhitelist.length; l++) { + var metadataKey = this.metadataWhitelist[l], + metadata = term.metadata[metadataKey] + + if (this.invertedIndex[term][fieldName][docRef][metadataKey] == undefined) { + this.invertedIndex[term][fieldName][docRef][metadataKey] = [] + } + + this.invertedIndex[term][fieldName][docRef][metadataKey].push(metadata) + } + } + + } +} + +/** + * Calculates the average document length for this index + * + * @private + */ +lunr.Builder.prototype.calculateAverageFieldLengths = function () { + + var fieldRefs = Object.keys(this.fieldLengths), + numberOfFields = fieldRefs.length, + accumulator = {}, + documentsWithField = {} + + for (var i = 0; i < numberOfFields; i++) { + var fieldRef = lunr.FieldRef.fromString(fieldRefs[i]), + field = fieldRef.fieldName + + documentsWithField[field] || (documentsWithField[field] = 0) + documentsWithField[field] += 1 + + accumulator[field] || (accumulator[field] = 0) + accumulator[field] += this.fieldLengths[fieldRef] + } + + for (var i = 0; i < this._fields.length; i++) { + var field = this._fields[i] + accumulator[field] = accumulator[field] / documentsWithField[field] + } + + this.averageFieldLength = accumulator +} + +/** + * Builds a vector space model of every document using lunr.Vector + * + * @private + */ +lunr.Builder.prototype.createFieldVectors = function () { + var fieldVectors = {}, + fieldRefs = Object.keys(this.fieldTermFrequencies), + fieldRefsLength = fieldRefs.length + + for (var i = 0; i < fieldRefsLength; i++) { + var fieldRef = lunr.FieldRef.fromString(fieldRefs[i]), + field = fieldRef.fieldName, + fieldLength = this.fieldLengths[fieldRef], + fieldVector = new lunr.Vector, + termFrequencies = this.fieldTermFrequencies[fieldRef], + terms = Object.keys(termFrequencies), + termsLength = terms.length + + for (var j = 0; j < termsLength; j++) { + var term = terms[j], + tf = termFrequencies[term], + termIndex = this.invertedIndex[term]._index, + idf = lunr.idf(this.invertedIndex[term], this.documentCount), + score = idf * ((this._k1 + 1) * tf) / (this._k1 * (1 - this._b + this._b * (fieldLength / this.averageFieldLength[field])) + tf), + scoreWithPrecision = Math.round(score * 1000) / 1000 + // Converts 1.23456789 to 1.234. + // Reducing the precision so that the vectors take up less + // space when serialised. Doing it now so that they behave + // the same before and after serialisation. Also, this is + // the fastest approach to reducing a number's precision in + // JavaScript. + + fieldVector.insert(termIndex, scoreWithPrecision) + } + + fieldVectors[fieldRef] = fieldVector + } + + this.fieldVectors = fieldVectors +} + +/** + * Creates a token set of all tokens in the index using lunr.TokenSet + * + * @private + */ +lunr.Builder.prototype.createTokenSet = function () { + this.tokenSet = lunr.TokenSet.fromArray( + Object.keys(this.invertedIndex).sort() + ) +} + +/** + * Builds the index, creating an instance of lunr.Index. + * + * This completes the indexing process and should only be called + * once all documents have been added to the index. + * + * @private + * @returns {lunr.Index} + */ +lunr.Builder.prototype.build = function () { + this.calculateAverageFieldLengths() + this.createFieldVectors() + this.createTokenSet() + + return new lunr.Index({ + invertedIndex: this.invertedIndex, + fieldVectors: this.fieldVectors, + tokenSet: this.tokenSet, + fields: this._fields, + pipeline: this.searchPipeline + }) +} + +/** + * Applies a plugin to the index builder. + * + * A plugin is a function that is called with the index builder as its context. + * Plugins can be used to customise or extend the behaviour of the index + * in some way. A plugin is just a function, that encapsulated the custom + * behaviour that should be applied when building the index. + * + * The plugin function will be called with the index builder as its argument, additional + * arguments can also be passed when calling use. The function will be called + * with the index builder as its context. + * + * @param {Function} plugin The plugin to apply. + */ +lunr.Builder.prototype.use = function (fn) { + var args = Array.prototype.slice.call(arguments, 1) + args.unshift(this) + fn.apply(this, args) +} +/** + * Contains and collects metadata about a matching document. + * A single instance of lunr.MatchData is returned as part of every + * lunr.Index~Result. + * + * @constructor + * @param {string} term - The term this match data is associated with + * @param {string} field - The field in which the term was found + * @param {object} metadata - The metadata recorded about this term in this field + * @property {object} metadata - A cloned collection of metadata associated with this document. + * @see {@link lunr.Index~Result} + */ +lunr.MatchData = function (term, field, metadata) { + var clonedMetadata = Object.create(null), + metadataKeys = Object.keys(metadata) + + // Cloning the metadata to prevent the original + // being mutated during match data combination. + // Metadata is kept in an array within the inverted + // index so cloning the data can be done with + // Array#slice + for (var i = 0; i < metadataKeys.length; i++) { + var key = metadataKeys[i] + clonedMetadata[key] = metadata[key].slice() + } + + this.metadata = Object.create(null) + this.metadata[term] = Object.create(null) + this.metadata[term][field] = clonedMetadata +} + +/** + * An instance of lunr.MatchData will be created for every term that matches a + * document. However only one instance is required in a lunr.Index~Result. This + * method combines metadata from another instance of lunr.MatchData with this + * objects metadata. + * + * @param {lunr.MatchData} otherMatchData - Another instance of match data to merge with this one. + * @see {@link lunr.Index~Result} + */ +lunr.MatchData.prototype.combine = function (otherMatchData) { + var terms = Object.keys(otherMatchData.metadata) + + for (var i = 0; i < terms.length; i++) { + var term = terms[i], + fields = Object.keys(otherMatchData.metadata[term]) + + if (this.metadata[term] == undefined) { + this.metadata[term] = Object.create(null) + } + + for (var j = 0; j < fields.length; j++) { + var field = fields[j], + keys = Object.keys(otherMatchData.metadata[term][field]) + + if (this.metadata[term][field] == undefined) { + this.metadata[term][field] = Object.create(null) + } + + for (var k = 0; k < keys.length; k++) { + var key = keys[k] + + if (this.metadata[term][field][key] == undefined) { + this.metadata[term][field][key] = otherMatchData.metadata[term][field][key] + } else { + this.metadata[term][field][key] = this.metadata[term][field][key].concat(otherMatchData.metadata[term][field][key]) + } + + } + } + } +} +/** + * A lunr.Query provides a programmatic way of defining queries to be performed + * against a {@link lunr.Index}. + * + * Prefer constructing a lunr.Query using the {@link lunr.Index#query} method + * so the query object is pre-initialized with the right index fields. + * + * @constructor + * @property {lunr.Query~Clause[]} clauses - An array of query clauses. + * @property {string[]} allFields - An array of all available fields in a lunr.Index. + */ +lunr.Query = function (allFields) { + this.clauses = [] + this.allFields = allFields +} + +/** + * Constants for indicating what kind of automatic wildcard insertion will be used when constructing a query clause. + * + * This allows wildcards to be added to the beginning and end of a term without having to manually do any string + * concatenation. + * + * The wildcard constants can be bitwise combined to select both leading and trailing wildcards. + * + * @constant + * @default + * @property {number} wildcard.NONE - The term will have no wildcards inserted, this is the default behaviour + * @property {number} wildcard.LEADING - Prepend the term with a wildcard, unless a leading wildcard already exists + * @property {number} wildcard.TRAILING - Append a wildcard to the term, unless a trailing wildcard already exists + * @see lunr.Query~Clause + * @see lunr.Query#clause + * @see lunr.Query#term + * @example query term with trailing wildcard + * query.term('foo', { wildcard: lunr.Query.wildcard.TRAILING }) + * @example query term with leading and trailing wildcard + * query.term('foo', { + * wildcard: lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING + * }) + */ +lunr.Query.wildcard = new String ("*") +lunr.Query.wildcard.NONE = 0 +lunr.Query.wildcard.LEADING = 1 +lunr.Query.wildcard.TRAILING = 2 + +/** + * A single clause in a {@link lunr.Query} contains a term and details on how to + * match that term against a {@link lunr.Index}. + * + * @typedef {Object} lunr.Query~Clause + * @property {string[]} fields - The fields in an index this clause should be matched against. + * @property {number} [boost=1] - Any boost that should be applied when matching this clause. + * @property {number} [editDistance] - Whether the term should have fuzzy matching applied, and how fuzzy the match should be. + * @property {boolean} [usePipeline] - Whether the term should be passed through the search pipeline. + * @property {number} [wildcard=0] - Whether the term should have wildcards appended or prepended. + */ + +/** + * Adds a {@link lunr.Query~Clause} to this query. + * + * Unless the clause contains the fields to be matched all fields will be matched. In addition + * a default boost of 1 is applied to the clause. + * + * @param {lunr.Query~Clause} clause - The clause to add to this query. + * @see lunr.Query~Clause + * @returns {lunr.Query} + */ +lunr.Query.prototype.clause = function (clause) { + if (!('fields' in clause)) { + clause.fields = this.allFields + } + + if (!('boost' in clause)) { + clause.boost = 1 + } + + if (!('usePipeline' in clause)) { + clause.usePipeline = true + } + + if (!('wildcard' in clause)) { + clause.wildcard = lunr.Query.wildcard.NONE + } + + if ((clause.wildcard & lunr.Query.wildcard.LEADING) && (clause.term.charAt(0) != lunr.Query.wildcard)) { + clause.term = "*" + clause.term + } + + if ((clause.wildcard & lunr.Query.wildcard.TRAILING) && (clause.term.slice(-1) != lunr.Query.wildcard)) { + clause.term = "" + clause.term + "*" + } + + this.clauses.push(clause) + + return this +} + +/** + * Adds a term to the current query, under the covers this will create a {@link lunr.Query~Clause} + * to the list of clauses that make up this query. + * + * @param {string} term - The term to add to the query. + * @param {Object} [options] - Any additional properties to add to the query clause. + * @returns {lunr.Query} + * @see lunr.Query#clause + * @see lunr.Query~Clause + * @example adding a single term to a query + * query.term("foo") + * @example adding a single term to a query and specifying search fields, term boost and automatic trailing wildcard + * query.term("foo", { + * fields: ["title"], + * boost: 10, + * wildcard: lunr.Query.wildcard.TRAILING + * }) + */ +lunr.Query.prototype.term = function (term, options) { + var clause = options || {} + clause.term = term + + this.clause(clause) + + return this +} +lunr.QueryParseError = function (message, start, end) { + this.name = "QueryParseError" + this.message = message + this.start = start + this.end = end +} + +lunr.QueryParseError.prototype = new Error +lunr.QueryLexer = function (str) { + this.lexemes = [] + this.str = str + this.length = str.length + this.pos = 0 + this.start = 0 + this.escapeCharPositions = [] +} + +lunr.QueryLexer.prototype.run = function () { + var state = lunr.QueryLexer.lexText + + while (state) { + state = state(this) + } +} + +lunr.QueryLexer.prototype.sliceString = function () { + var subSlices = [], + sliceStart = this.start, + sliceEnd = this.pos + + for (var i = 0; i < this.escapeCharPositions.length; i++) { + sliceEnd = this.escapeCharPositions[i] + subSlices.push(this.str.slice(sliceStart, sliceEnd)) + sliceStart = sliceEnd + 1 + } + + subSlices.push(this.str.slice(sliceStart, this.pos)) + this.escapeCharPositions.length = 0 + + return subSlices.join('') +} + +lunr.QueryLexer.prototype.emit = function (type) { + this.lexemes.push({ + type: type, + str: this.sliceString(), + start: this.start, + end: this.pos + }) + + this.start = this.pos +} + +lunr.QueryLexer.prototype.escapeCharacter = function () { + this.escapeCharPositions.push(this.pos - 1) + this.pos += 1 +} + +lunr.QueryLexer.prototype.next = function () { + if (this.pos >= this.length) { + return lunr.QueryLexer.EOS + } + + var char = this.str.charAt(this.pos) + this.pos += 1 + return char +} + +lunr.QueryLexer.prototype.width = function () { + return this.pos - this.start +} + +lunr.QueryLexer.prototype.ignore = function () { + if (this.start == this.pos) { + this.pos += 1 + } + + this.start = this.pos +} + +lunr.QueryLexer.prototype.backup = function () { + this.pos -= 1 +} + +lunr.QueryLexer.prototype.acceptDigitRun = function () { + var char, charCode + + do { + char = this.next() + charCode = char.charCodeAt(0) + } while (charCode > 47 && charCode < 58) + + if (char != lunr.QueryLexer.EOS) { + this.backup() + } +} + +lunr.QueryLexer.prototype.more = function () { + return this.pos < this.length +} + +lunr.QueryLexer.EOS = 'EOS' +lunr.QueryLexer.FIELD = 'FIELD' +lunr.QueryLexer.TERM = 'TERM' +lunr.QueryLexer.EDIT_DISTANCE = 'EDIT_DISTANCE' +lunr.QueryLexer.BOOST = 'BOOST' + +lunr.QueryLexer.lexField = function (lexer) { + lexer.backup() + lexer.emit(lunr.QueryLexer.FIELD) + lexer.ignore() + return lunr.QueryLexer.lexText +} + +lunr.QueryLexer.lexTerm = function (lexer) { + if (lexer.width() > 1) { + lexer.backup() + lexer.emit(lunr.QueryLexer.TERM) + } + + lexer.ignore() + + if (lexer.more()) { + return lunr.QueryLexer.lexText + } +} + +lunr.QueryLexer.lexEditDistance = function (lexer) { + lexer.ignore() + lexer.acceptDigitRun() + lexer.emit(lunr.QueryLexer.EDIT_DISTANCE) + return lunr.QueryLexer.lexText +} + +lunr.QueryLexer.lexBoost = function (lexer) { + lexer.ignore() + lexer.acceptDigitRun() + lexer.emit(lunr.QueryLexer.BOOST) + return lunr.QueryLexer.lexText +} + +lunr.QueryLexer.lexEOS = function (lexer) { + if (lexer.width() > 0) { + lexer.emit(lunr.QueryLexer.TERM) + } +} + +// This matches the separator used when tokenising fields +// within a document. These should match otherwise it is +// not possible to search for some tokens within a document. +// +// It is possible for the user to change the separator on the +// tokenizer so it _might_ clash with any other of the special +// characters already used within the search string, e.g. :. +// +// This means that it is possible to change the separator in +// such a way that makes some words unsearchable using a search +// string. +lunr.QueryLexer.termSeparator = lunr.tokenizer.separator + +lunr.QueryLexer.lexText = function (lexer) { + while (true) { + var char = lexer.next() + + if (char == lunr.QueryLexer.EOS) { + return lunr.QueryLexer.lexEOS + } + + // Escape character is '\' + if (char.charCodeAt(0) == 92) { + lexer.escapeCharacter() + continue + } + + if (char == ":") { + return lunr.QueryLexer.lexField + } + + if (char == "~") { + lexer.backup() + if (lexer.width() > 0) { + lexer.emit(lunr.QueryLexer.TERM) + } + return lunr.QueryLexer.lexEditDistance + } + + if (char == "^") { + lexer.backup() + if (lexer.width() > 0) { + lexer.emit(lunr.QueryLexer.TERM) + } + return lunr.QueryLexer.lexBoost + } + + if (char.match(lunr.QueryLexer.termSeparator)) { + return lunr.QueryLexer.lexTerm + } + } +} + +lunr.QueryParser = function (str, query) { + this.lexer = new lunr.QueryLexer (str) + this.query = query + this.currentClause = {} + this.lexemeIdx = 0 +} + +lunr.QueryParser.prototype.parse = function () { + this.lexer.run() + this.lexemes = this.lexer.lexemes + + var state = lunr.QueryParser.parseFieldOrTerm + + while (state) { + state = state(this) + } + + return this.query +} + +lunr.QueryParser.prototype.peekLexeme = function () { + return this.lexemes[this.lexemeIdx] +} + +lunr.QueryParser.prototype.consumeLexeme = function () { + var lexeme = this.peekLexeme() + this.lexemeIdx += 1 + return lexeme +} + +lunr.QueryParser.prototype.nextClause = function () { + var completedClause = this.currentClause + this.query.clause(completedClause) + this.currentClause = {} +} + +lunr.QueryParser.parseFieldOrTerm = function (parser) { + var lexeme = parser.peekLexeme() + + if (lexeme == undefined) { + return + } + + switch (lexeme.type) { + case lunr.QueryLexer.FIELD: + return lunr.QueryParser.parseField + case lunr.QueryLexer.TERM: + return lunr.QueryParser.parseTerm + default: + var errorMessage = "expected either a field or a term, found " + lexeme.type + + if (lexeme.str.length >= 1) { + errorMessage += " with value '" + lexeme.str + "'" + } + + throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) + } +} + +lunr.QueryParser.parseField = function (parser) { + var lexeme = parser.consumeLexeme() + + if (lexeme == undefined) { + return + } + + if (parser.query.allFields.indexOf(lexeme.str) == -1) { + var possibleFields = parser.query.allFields.map(function (f) { return "'" + f + "'" }).join(', '), + errorMessage = "unrecognised field '" + lexeme.str + "', possible fields: " + possibleFields + + throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) + } + + parser.currentClause.fields = [lexeme.str] + + var nextLexeme = parser.peekLexeme() + + if (nextLexeme == undefined) { + var errorMessage = "expecting term, found nothing" + throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) + } + + switch (nextLexeme.type) { + case lunr.QueryLexer.TERM: + return lunr.QueryParser.parseTerm + default: + var errorMessage = "expecting term, found '" + nextLexeme.type + "'" + throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) + } +} + +lunr.QueryParser.parseTerm = function (parser) { + var lexeme = parser.consumeLexeme() + + if (lexeme == undefined) { + return + } + + parser.currentClause.term = lexeme.str.toLowerCase() + + if (lexeme.str.indexOf("*") != -1) { + parser.currentClause.usePipeline = false + } + + var nextLexeme = parser.peekLexeme() + + if (nextLexeme == undefined) { + parser.nextClause() + return + } + + switch (nextLexeme.type) { + case lunr.QueryLexer.TERM: + parser.nextClause() + return lunr.QueryParser.parseTerm + case lunr.QueryLexer.FIELD: + parser.nextClause() + return lunr.QueryParser.parseField + case lunr.QueryLexer.EDIT_DISTANCE: + return lunr.QueryParser.parseEditDistance + case lunr.QueryLexer.BOOST: + return lunr.QueryParser.parseBoost + default: + var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'" + throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) + } +} + +lunr.QueryParser.parseEditDistance = function (parser) { + var lexeme = parser.consumeLexeme() + + if (lexeme == undefined) { + return + } + + var editDistance = parseInt(lexeme.str, 10) + + if (isNaN(editDistance)) { + var errorMessage = "edit distance must be numeric" + throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) + } + + parser.currentClause.editDistance = editDistance + + var nextLexeme = parser.peekLexeme() + + if (nextLexeme == undefined) { + parser.nextClause() + return + } + + switch (nextLexeme.type) { + case lunr.QueryLexer.TERM: + parser.nextClause() + return lunr.QueryParser.parseTerm + case lunr.QueryLexer.FIELD: + parser.nextClause() + return lunr.QueryParser.parseField + case lunr.QueryLexer.EDIT_DISTANCE: + return lunr.QueryParser.parseEditDistance + case lunr.QueryLexer.BOOST: + return lunr.QueryParser.parseBoost + default: + var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'" + throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) + } +} + +lunr.QueryParser.parseBoost = function (parser) { + var lexeme = parser.consumeLexeme() + + if (lexeme == undefined) { + return + } + + var boost = parseInt(lexeme.str, 10) + + if (isNaN(boost)) { + var errorMessage = "boost must be numeric" + throw new lunr.QueryParseError (errorMessage, lexeme.start, lexeme.end) + } + + parser.currentClause.boost = boost + + var nextLexeme = parser.peekLexeme() + + if (nextLexeme == undefined) { + parser.nextClause() + return + } + + switch (nextLexeme.type) { + case lunr.QueryLexer.TERM: + parser.nextClause() + return lunr.QueryParser.parseTerm + case lunr.QueryLexer.FIELD: + parser.nextClause() + return lunr.QueryParser.parseField + case lunr.QueryLexer.EDIT_DISTANCE: + return lunr.QueryParser.parseEditDistance + case lunr.QueryLexer.BOOST: + return lunr.QueryParser.parseBoost + default: + var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'" + throw new lunr.QueryParseError (errorMessage, nextLexeme.start, nextLexeme.end) + } +} + + /** + * export the module via AMD, CommonJS or as a browser global + * Export code from https://github.com/umdjs/umd/blob/master/returnExports.js + */ + ;(function (root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(factory) + } else if (typeof exports === 'object') { + /** + * Node. Does not work with strict CommonJS, but + * only CommonJS-like enviroments that support module.exports, + * like Node. + */ + module.exports = factory() + } else { + // Browser globals (root is window) + root.lunr = factory() + } + }(this, function () { + /** + * Just return a value to define the module export. + * This example returns an object, but the module + * can return a function as the exported value. + */ + return lunr + })) +})(); diff --git a/docs/docfx/templates/Werkr/styles/toggle-theme.js b/docs/docfx/templates/Werkr/styles/toggle-theme.js index f2ea5f5..f1b3809 100644 --- a/docs/docfx/templates/Werkr/styles/toggle-theme.js +++ b/docs/docfx/templates/Werkr/styles/toggle-theme.js @@ -1,35 +1,35 @@ -const sw = document.getElementById("switch-style"), sw_mobile = document.getElementById("switch-style-m"), b = document.body; -if (b) { - function toggleTheme(target, dark) { - target.classList.toggle("dark-theme", dark) - target.classList.toggle("light-theme", !dark) - } - - function switchEventListener() { - toggleTheme(b, this.checked); - if (window.localStorage) { - this.checked ? localStorage.setItem("theme", "dark-theme") : localStorage.setItem("theme", "light-theme") - } - } - - var isDarkTheme = !window.localStorage || !window.localStorage.getItem("theme") || window.localStorage && localStorage.getItem("theme") === "dark-theme"; - - if(sw && sw_mobile){ - sw.checked = isDarkTheme; - sw_mobile.checked = isDarkTheme; - - sw.addEventListener("change", switchEventListener); - sw_mobile.addEventListener("change", switchEventListener); - - // sync state between switches - sw.addEventListener("change", function() { - sw_mobile.checked = this.checked; - }); - - sw_mobile.addEventListener("change", function() { - sw.checked = this.checked; - }); - } - - toggleTheme(b, isDarkTheme); +const sw = document.getElementById("switch-style"), sw_mobile = document.getElementById("switch-style-m"), b = document.body; +if (b) { + function toggleTheme(target, dark) { + target.classList.toggle("dark-theme", dark) + target.classList.toggle("light-theme", !dark) + } + + function switchEventListener() { + toggleTheme(b, this.checked); + if (window.localStorage) { + this.checked ? localStorage.setItem("theme", "dark-theme") : localStorage.setItem("theme", "light-theme") + } + } + + var isDarkTheme = !window.localStorage || !window.localStorage.getItem("theme") || window.localStorage && localStorage.getItem("theme") === "dark-theme"; + + if(sw && sw_mobile){ + sw.checked = isDarkTheme; + sw_mobile.checked = isDarkTheme; + + sw.addEventListener("change", switchEventListener); + sw_mobile.addEventListener("change", switchEventListener); + + // sync state between switches + sw.addEventListener("change", function() { + sw_mobile.checked = this.checked; + }); + + sw_mobile.addEventListener("change", function() { + sw.checked = this.checked; + }); + } + + toggleTheme(b, isDarkTheme); } \ No newline at end of file diff --git a/docs/images/WerkrAgentMsiDialog.bmp b/docs/images/WerkrAgentMsiDialog.bmp new file mode 100644 index 0000000000000000000000000000000000000000..9616ee887ca5b6279c2b3b4e128c2b263ab7dfbe GIT binary patch literal 615414 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/images/WerkrLogoWithText_GithubBanner.png b/docs/images/WerkrLogoWithText_GithubBanner.png new file mode 100644 index 0000000000000000000000000000000000000000..d7367649570719b3cd1c14f31418a7caeec744d1 GIT binary patch literal 104366 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

R~XJ1niH^RM>CFM@!z>k^dW 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 VBtQZrKmsH{0wh2JB=DOd@L%q|O+Nqt literal 0 HcmV?d00001 diff --git a/docs/images/WerkrServerMsiDialog.bmp b/docs/images/WerkrServerMsiDialog.bmp new file mode 100644 index 0000000000000000000000000000000000000000..512f0be9a24ec66a4328725eb1c80f80a370b107 GIT binary patch literal 615414 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 I_|6IZfB6F +[CmdletBinding()] +param ( + [ValidateSet('all', 'server', 'api', 'agent')] + [string]$Target = 'all', + + [string]$Registry = ($env:DOCKER_REGISTRY ?? 'ghcr.io/werkr'), + + [string]$Tag = ($env:DOCKER_TAG ?? 'latest'), + + [switch]$Push, + + [switch]$Deb, + + [Parameter(HelpMessage = 'Skip TLS certificate generation (use your own certs).')] + [switch]$SkipCertGeneration, + + [Parameter(HelpMessage = 'Force-regenerate TLS certificates even if they already exist.')] + [switch]$GenerateCerts, + + [Parameter(HelpMessage = 'Trust the Werkr dev CA in the OS certificate trust store. Requires elevation on Windows/macOS.')] + [switch]$TrustCA +) +$ErrorActionPreference = 'Stop' + +[string]$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +[string]$BuildMode = $Deb ? 'deb' : 'source' +[string]$CertsDir = Join-Path $RepoRoot 'certs' + +#region Certificate Generation + +function Assert-OpenSslInstalled { +<# + .SYNOPSIS + Assert that openssl is available on the PATH. +#> + [CmdletBinding()] + [OutputType([System.Void])] + param () + + [string]$OpenSsl = Get-Command openssl -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source + if ([string]::IsNullOrWhiteSpace($OpenSsl)) { + throw 'openssl is not installed or not on PATH. Install OpenSSL to generate TLS certificates.' + } + Write-Verbose "openssl found at: $OpenSsl" +} + +function New-WerkrCertificates { +<# + .SYNOPSIS + Generate a local dev CA and per-service TLS certificates for Docker. + .DESCRIPTION + Creates: + - A self-signed CA (werkr-ca.pem, werkr-ca-key.pem) + - A control-plane cert for Server + API (werkr-server.pfx) + SANs: localhost, werkr-api, werkr-server + - An agent cert (werkr-agent.pfx) + SANs: localhost, werkr-agent + All files are written to the certs/ directory at the repo root. +#> + [CmdletBinding()] + [OutputType([System.Void])] + param ( + [Parameter(Mandatory)] + [string]$OutputDir + ) + + Assert-OpenSslInstalled + + [string]$CaKey = Join-Path $OutputDir 'werkr-ca-key.pem' + [string]$CaCert = Join-Path $OutputDir 'werkr-ca.pem' + [string]$PfxPass = 'werkr-dev' + + $null = New-Item -ItemType Directory -Path $OutputDir -Force + + Write-Host '==> Generating Werkr dev CA...' + + # Generate CA private key + & openssl genpkey -algorithm RSA -out $CaKey -pkeyopt rsa_keygen_bits:4096 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Failed to generate CA private key' } + + # Generate CA certificate (10-year validity) + & openssl req -x509 -new -nodes -key $CaKey -sha256 -days 3650 -subj '/CN=Werkr Dev CA/O=Werkr/OU=Development' -out $CaCert 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Failed to generate CA certificate' } + + # --- Control plane cert (Server + API) --- + Write-Host '==> Generating control plane certificate (Server + API)...' + $ServerCertParams = @{ + Name = 'werkr-server' + SANs = @('localhost', 'werkr-api', 'werkr-server') + CaKey = $CaKey + CaCert = $CaCert + OutputDir = $OutputDir + PfxPassword = $PfxPass + } + New-ServiceCertificate @ServerCertParams + + # --- Agent cert --- + Write-Host '==> Generating agent certificate...' + $AgentCertParams = @{ + Name = 'werkr-agent' + SANs = @('localhost', 'werkr-agent') + CaKey = $CaKey + CaCert = $CaCert + OutputDir = $OutputDir + PfxPassword = $PfxPass + } + New-ServiceCertificate @AgentCertParams + + Write-Host "==> Certificates generated in $OutputDir" -ForegroundColor Green + Write-Host " CA: werkr-ca.pem" + Write-Host " Server: werkr-server.pfx (password: $PfxPass)" + Write-Host " Agent: werkr-agent.pfx (password: $PfxPass)" + Write-Host '' + Write-Host 'NOTE: Your browser will not trust these certificates by default.' -ForegroundColor Yellow + Write-Host 'To enable interactive Blazor features, trust the CA in your OS:' -ForegroundColor Yellow + Write-Host '' + Write-Host ' macOS: sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certs/werkr-ca.pem' -ForegroundColor Cyan + Write-Host ' Windows: Import-Certificate -FilePath certs\werkr-ca.pem -CertStoreLocation Cert:\LocalMachine\Root' -ForegroundColor Cyan + Write-Host ' Linux: sudo cp certs/werkr-ca.pem /usr/local/share/ca-certificates/werkr-ca.crt && sudo update-ca-certificates' -ForegroundColor Cyan + Write-Host '' + Write-Host 'Or re-run this script with -TrustCA to do it automatically.' -ForegroundColor Yellow +} + +function New-ServiceCertificate { +<# + .SYNOPSIS + Generate a TLS certificate signed by the Werkr dev CA. +#> + [CmdletBinding()] + [OutputType([System.Void])] + param ( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string[]]$SANs, + [Parameter(Mandatory)][string]$CaKey, + [Parameter(Mandatory)][string]$CaCert, + [Parameter(Mandatory)][string]$OutputDir, + [Parameter(Mandatory)][string]$PfxPassword + ) + + [string]$KeyFile = Join-Path $OutputDir "$Name-key.pem" + [string]$CsrFile = Join-Path $OutputDir "$Name.csr" + [string]$CertFile = Join-Path $OutputDir "$Name.pem" + [string]$PfxFile = Join-Path $OutputDir "$Name.pfx" + [string]$ExtFile = Join-Path $OutputDir "$Name-ext.cnf" + + # Build SAN extension config + [string[]]$DnsEntries = @() + for ([int]$i = 0; $i -lt $SANs.Count; $i++) { + $DnsEntries += "DNS.$($i + 1) = $($SANs[$i])" + } + [string]$ExtContent = @" +authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names + +[alt_names] +$($DnsEntries -join "`n") +"@ + Set-Content -Path $ExtFile -Value $ExtContent -NoNewline + + # Generate service private key + & openssl genpkey -algorithm RSA -out $KeyFile -pkeyopt rsa_keygen_bits:2048 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to generate private key for $Name" } + + # Generate CSR + & openssl req -new -key $KeyFile -out $CsrFile -subj "/CN=$($SANs[0])/O=Werkr/OU=$Name" 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to generate CSR for $Name" } + + # Sign with CA (2-year validity) + & openssl x509 -req -in $CsrFile -CA $CaCert -CAkey $CaKey -CAcreateserial -out $CertFile -days 730 -sha256 -extfile $ExtFile 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to sign certificate for $Name" } + + # Export to PFX + & openssl pkcs12 -export -out $PfxFile -inkey $KeyFile -in $CertFile -certfile $CaCert -password "pass:$PfxPassword" 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to export PFX for $Name" } + + # Clean up intermediate files + Remove-Item -Path $CsrFile, $ExtFile -Force -ErrorAction SilentlyContinue +} + +#endregion Certificate Generation + +# --- Certificate generation (default on first run) --- +if ($GenerateCerts) { + New-WerkrCertificates -OutputDir $CertsDir +} elseif (-not $SkipCertGeneration -and -not (Test-Path $CertsDir)) { + Write-Host '==> No certs/ directory found. Generating TLS certificates for Docker...' + New-WerkrCertificates -OutputDir $CertsDir +} elseif (Test-Path $CertsDir) { + Write-Verbose 'certs/ directory exists, skipping certificate generation.' +} + +# --- Trust CA in OS trust store --- +if ($TrustCA) { + [string]$CaPem = Join-Path $CertsDir 'werkr-ca.pem' + if (-not (Test-Path $CaPem)) { + throw "CA certificate not found at $CaPem. Run with -GenerateCerts first." + } + + if ($IsWindows) { + Write-Host '==> Trusting Werkr dev CA in Windows certificate store (requires elevation)...' + Import-Certificate -FilePath $CaPem -CertStoreLocation 'Cert:\LocalMachine\Root' | Out-Null + } elseif ($IsMacOS) { + Write-Host '==> Trusting Werkr dev CA in macOS System Keychain (requires sudo)...' + & sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain $CaPem + if ($LASTEXITCODE -ne 0) { throw 'Failed to trust CA on macOS.' } + } elseif ($IsLinux) { + Write-Host '==> Trusting Werkr dev CA in Linux CA store (requires sudo)...' + & sudo cp $CaPem /usr/local/share/ca-certificates/werkr-ca.crt + & sudo update-ca-certificates + if ($LASTEXITCODE -ne 0) { throw 'Failed to trust CA on Linux.' } + } else { + Write-Warning 'Unknown OS — cannot auto-trust. Please trust certs/werkr-ca.pem manually.' + } + Write-Host '==> CA trusted successfully.' -ForegroundColor Green +} + +# If .deb mode, run publish.ps1 first to produce the .deb packages +if ($Deb) { + Write-Host '==> Publishing .deb packages via publish.ps1...' + $DebParams = @{ + Application = 'All' + Platform = 'linux' + Architecture = 'x64' + BuildDebInstallers = $true + SkipCompression = $true + } + & pwsh (Join-Path $PSScriptRoot 'publish.ps1') @DebParams + if ($LASTEXITCODE -ne 0) { throw 'publish.ps1 failed' } + Write-Host '==> .deb packages ready in Publish/' +} + +function Build-Image { + param ( + [string]$Name, + [string]$Dockerfile + ) + [string]$Image = "$Registry/werkr-${Name}:$Tag" + Write-Host "==> Building $Image (mode: $BuildMode)" + & docker build -t $Image -f (Join-Path $RepoRoot $Dockerfile) --platform linux/amd64 --build-arg "BUILD_MODE=$BuildMode" $RepoRoot + if ($LASTEXITCODE -ne 0) { throw "Docker build failed for $Name" } + if ($Push) { + Write-Host "==> Pushing $Image" + & docker push $Image + if ($LASTEXITCODE -ne 0) { throw "Docker push failed for $Name" } + } +} + +$Images = @{ + server = 'src/Werkr.Server/Dockerfile' + api = 'src/Werkr.Api/Dockerfile' + agent = 'src/Werkr.Agent/Dockerfile' +} + +if ($Target -eq 'all') { + foreach ($entry in $Images.GetEnumerator()) { + Build-Image -Name $entry.Key -Dockerfile $entry.Value + } +} else { + Build-Image -Name $Target -Dockerfile $Images[$Target] +} + +Write-Host 'Done.' -ForegroundColor Green diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh new file mode 100644 index 0000000..77afd1a --- /dev/null +++ b/scripts/docker-build.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# docker-build.sh — Build Werkr Docker images +# +# Usage: +# ./scripts/docker-build.sh # source build (default) +# ./scripts/docker-build.sh --deb # publish .deb then build +# ./scripts/docker-build.sh server # build server only +# ./scripts/docker-build.sh agent # build agent only +# ./scripts/docker-build.sh api # build api only +# ./scripts/docker-build.sh --push # build and push all +# ./scripts/docker-build.sh --deb --push # publish, build, push +# --------------------------------------------------------------------------- +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +REGISTRY="${DOCKER_REGISTRY:-ghcr.io/werkr}" +TAG="${DOCKER_TAG:-latest}" +PUSH=false +TARGET="" +BUILD_MODE="source" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --deb) BUILD_MODE="deb"; shift ;; + --push) PUSH=true; shift ;; + --tag) TAG="$2"; shift 2 ;; + --registry) REGISTRY="$2"; shift 2 ;; + server|api|agent) TARGET="$1"; shift ;; + *) echo "Unknown argument: $1"; exit 1 ;; + esac +done + +# If .deb mode, run publish.ps1 first to produce the .deb packages +if [[ "$BUILD_MODE" == "deb" ]]; then + echo "==> Publishing .deb packages via publish.ps1..." + pwsh "$SCRIPT_DIR/publish.ps1" \ + -Application All \ + -Platform linux \ + -Architecture x64 \ + -BuildDebInstallers \ + -SkipCompression + echo "==> .deb packages ready in Publish/" +fi + +build_image() { + local name="$1" + local dockerfile="$2" + local image="${REGISTRY}/werkr-${name}:${TAG}" + + echo "==> Building ${image} (mode: ${BUILD_MODE})" + docker build \ + -t "${image}" \ + -f "${REPO_ROOT}/${dockerfile}" \ + --build-arg BUILD_MODE="${BUILD_MODE}" \ + "${REPO_ROOT}" + + if [ "$PUSH" = true ]; then + echo "==> Pushing ${image}" + docker push "${image}" + fi +} + +# Build requested image(s) +case "${TARGET:-all}" in + server) build_image "server" "src/Werkr.Server/Dockerfile" ;; + api) build_image "api" "src/Werkr.Api/Dockerfile" ;; + agent) build_image "agent" "src/Werkr.Agent/Dockerfile" ;; + all) + build_image "server" "src/Werkr.Server/Dockerfile" + build_image "api" "src/Werkr.Api/Dockerfile" + build_image "agent" "src/Werkr.Agent/Dockerfile" + ;; +esac + +echo "Done." diff --git a/scripts/publish.ps1 b/scripts/publish.ps1 new file mode 100644 index 0000000..441c180 --- /dev/null +++ b/scripts/publish.ps1 @@ -0,0 +1,1170 @@ +#Requires -Version 7.2 +using namespace System.IO +<# + .SYNOPSIS + Build, publish, and package Werkr products for all supported platforms. + + .DESCRIPTION + This script publishes Werkr applications as self-contained, single-file + executables and optionally creates platform-specific installers: + - Windows : MSI (WiX 6 SDK-style) + - Linux : .deb package with debconf, systemd service, non-root user + - macOS : .app bundle with launcher script + + GitVersion is used for semantic versioning. If dotnet-gitversion is not + available or the workspace is not a git repo, the script falls back to + version 0.0.1-local. + + .EXAMPLE + ./scripts/publish.ps1 -Application Agent -Platform linux -Architecture arm64 -BuildDebInstallers + .EXAMPLE + ./scripts/publish.ps1 -Verbose +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory = $false)] + [ValidateSet('All', 'ServerBundle', 'Agent')] + [string]$Application = 'All', + + [Parameter(Mandatory = $false)] + [ValidateSet('All', 'x64', 'arm64')] + [string]$Architecture = 'All', + + [Parameter(Mandatory = $false)] + [ValidateSet('All', 'windows', 'linux', 'macos')] + [string]$Platform = 'All', + + [Parameter(Mandatory = $false)] + [switch]$BuildMsiInstallers, + + [Parameter(Mandatory = $false)] + [switch]$BuildDebInstallers, + + [Parameter(Mandatory = $false)] + [switch]$BuildMacOSPackage, + + [Parameter(Mandatory = $false)] + [switch]$SkipCompression, + + [Parameter(Mandatory = $false)] + [switch]$SkipTar +) +$ErrorActionPreference = 'Stop' +[bool]$Verbose = ($PSBoundParameters.ContainsKey('Verbose')) ? $PSBoundParameters['Verbose'] : $false +Set-StrictMode -Version Latest + +#region functions + +function Assert-DotnetInstalled { +<# + .SYNOPSIS + Assert that dotnet is installed and meets the minimum version requirement. +#> + [CmdletBinding()] + [OutputType([System.Void])] + param ( + [Parameter(Mandatory)] + [int]$DotNetVersion + ) + + [string]$DotNetCommand = Get-Command dotnet -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source + if ([string]::IsNullOrWhiteSpace($DotNetCommand)) { + throw "dotnet SDK is not installed. Install .NET $DotNetVersion SDK from https://dotnet.microsoft.com/download" + } + + [version]$InstalledVersion = & dotnet --version | ForEach-Object { [version]::new($_) } + if ($InstalledVersion.Major -lt $DotNetVersion) { + throw "dotnet $($InstalledVersion) does not meet the minimum version ($DotNetVersion). Update from https://dotnet.microsoft.com/download" + } + Write-Verbose "dotnet $InstalledVersion OK (minimum $DotNetVersion)" +} + +function Assert-DpkgDebInstalled { +<# + .SYNOPSIS + Assert that dpkg-deb is available when .deb installers are requested. +#> + [CmdletBinding()] + [OutputType([System.Void])] + param ( + [Parameter(Mandatory)] + [bool]$BuildDebInstallers + ) + + if (-not $BuildDebInstallers) { return } + + [string]$DpkgDeb = Get-Command dpkg-deb -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source + if ([string]::IsNullOrWhiteSpace($DpkgDeb)) { + throw 'dpkg-deb is not installed. On Ubuntu/Debian: sudo apt-get install dpkg' + } + Write-Verbose "dpkg-deb found at $DpkgDeb" +} + +function Assert-TarInstalled { +<# + .SYNOPSIS + Assert that tar is available when .tar.gz output is requested. +#> + [CmdletBinding()] + [OutputType([System.Void])] + param ( + [Parameter(Mandatory)] + [bool]$SkipTar + ) + + if ($SkipTar) { return } + if (-not ($IsLinux -or $IsMacOS)) { return } + + [string]$Tar = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source + if ([string]::IsNullOrWhiteSpace($Tar)) { + throw 'tar is not installed. Install tar or use -SkipTar.' + } + Write-Verbose "tar found at $Tar" +} + +function Get-GitVersion { +<# + .SYNOPSIS + Obtain semantic version from GitVersion. Falls back to 0.0.1-local. + .OUTPUTS + [hashtable] with keys: SemVer, MajorMinorPatch, Major, Minor, Patch, PreReleaseTag, InformationalVersion +#> + [CmdletBinding()] + [OutputType([hashtable])] + param () + + try { + [string]$RawJson = & dotnet gitversion /output json 2>$null + if ($LASTEXITCODE -ne 0) { throw 'gitversion exited with non-zero' } + $GV = $RawJson | ConvertFrom-Json + return @{ + SemVer = $GV.SemVer + MajorMinorPatch = $GV.MajorMinorPatch + Major = $GV.Major + Minor = $GV.Minor + Patch = $GV.Patch + PreReleaseTag = $GV.PreReleaseTag + InformationalVersion = $GV.InformationalVersion + } + } + catch { + Write-Warning "GitVersion unavailable — using fallback 0.0.1-local. ($_)" + return @{ + SemVer = '0.0.1-local' + MajorMinorPatch = '0.0.1' + Major = 0 + Minor = 0 + Patch = 1 + PreReleaseTag = 'local' + InformationalVersion = '0.0.1-local' + } + } +} + +function Set-GrpcToolsArm64Directory { +<# + .SYNOPSIS + When cross-compiling for Windows ARM64, point the Grpc.Tools package at + the x64 native tools (Grpc.Tools ships no ARM64 protoc for Windows). +#> + [CmdletBinding()] + [OutputType([System.Void])] + param ( + [Parameter(Mandatory)] + [string]$ProjectPath, + + [Parameter(Mandatory)] + [string]$RuntimeIdentifier, + + [Parameter(Mandatory)] + [string]$Arch + ) + + if ($Arch -ine 'arm64' -or $RuntimeIdentifier -inotlike 'win-*') { return } + + # NuGet global-packages cache + [string]$NuGetGlobalPackages = & dotnet nuget locals global-packages --list | + ForEach-Object { ($_ -split ':\s*', 2)[1] } | + Select-Object -First 1 + + [string]$GrpcToolsDir = Get-ChildItem -Path (Join-Path $NuGetGlobalPackages 'grpc.tools') -Directory | + Sort-Object Name -Descending | Select-Object -First 1 -ExpandProperty FullName + + if ([string]::IsNullOrWhiteSpace($GrpcToolsDir)) { + Write-Warning 'Could not locate Grpc.Tools package — skipping ARM64 override.' + return + } + + [Environment]::SetEnvironmentVariable('GRPC_PROTOC_PLUGIN_DIR', + (Join-Path $GrpcToolsDir 'tools' 'windows_x64')) + Write-Verbose "GRPC_PROTOC_PLUGIN_DIR → $(Join-Path $GrpcToolsDir 'tools' 'windows_x64')" +} + +function New-Executable { +<# + .SYNOPSIS + Publish a self-contained, single-file executable using dotnet publish. +#> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [string]$OutputPath, + + [Parameter(Mandatory)] + [string]$ProjectPath, + + [Parameter(Mandatory)] + [string]$RuntimeIdentifier, + + [Parameter(Mandatory)] + [hashtable]$VersionInfo, + + [Parameter(Mandatory)] + [int]$Counter + ) + + Write-Host "[$Counter] Publishing $RuntimeIdentifier → $OutputPath" + [string[]]$PublishArgs = @( + 'publish' + $ProjectPath + '-c', 'Release' + '-r', $RuntimeIdentifier + '-o', $OutputPath + '--sc', 'true' + '-p:PublishSingleFile=true' + "-p:Version=$($VersionInfo.MajorMinorPatch)" + "-p:AssemblyVersion=$($VersionInfo.Major).$($VersionInfo.Minor).$($VersionInfo.Patch).0" + "-p:FileVersion=$($VersionInfo.Major).$($VersionInfo.Minor).$($VersionInfo.Patch).0" + "-p:InformationalVersion=$($VersionInfo.InformationalVersion)" + ) + & dotnet @PublishArgs + if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed for $RuntimeIdentifier (exit $LASTEXITCODE)" } + + return $Counter + 1 +} + +function Build-Installer { +<# + .SYNOPSIS + Dispatch to the appropriate installer builder based on OS. +#> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [string]$OS, + + [Parameter(Mandatory)] + [bool]$BuildMsiInstallers, + + [Parameter(Mandatory)] + [bool]$BuildDebInstallers, + + [Parameter(Mandatory)] + [bool]$BuildMacOSPackage, + + [Parameter(Mandatory)] + [string]$ProductType, + + [Parameter(Mandatory)] + [string]$RuntimeIdentifier, + + [Parameter(Mandatory)] + [string]$Arch, + + [Parameter(Mandatory)] + [hashtable]$VersionInfo, + + [Parameter(Mandatory)] + [string]$EditionName, + + [Parameter(Mandatory)] + [string]$OutputPath, + + [Parameter(Mandatory)] + [string]$PublishPath, + + [Parameter(Mandatory)] + [int]$Counter + ) + + switch ($OS) { + 'windows' { + if ($BuildMsiInstallers) { + $MsiPackageParams = @{ + ProductType = $ProductType + RuntimeIdentifier = $RuntimeIdentifier + Arch = $Arch + VersionInfo = $VersionInfo + EditionName = $EditionName + PublishPath = $PublishPath + Counter = $Counter + Verbose = $Verbose + } + $Counter = New-MsiInstaller @MsiPackageParams + } + } + 'linux' { + if ($BuildDebInstallers) { + $DebPackageParams = @{ + ProductType = $ProductType + RuntimeIdentifier = $RuntimeIdentifier + VersionInfo = $VersionInfo + EditionName = $EditionName + OutputPath = $OutputPath + PublishPath = $PublishPath + Counter = $Counter + Verbose = $Verbose + } + $Counter = New-DebPackage @DebPackageParams + } + } + 'macos' { + if ($BuildMacOSPackage) { + $MacOSPackageParams = @{ + ProductType = $ProductType + VersionInfo = $VersionInfo + EditionName = $EditionName + OutputPath = $OutputPath + PublishPath = $PublishPath + Counter = $Counter + Verbose = $Verbose + } + $Counter = New-MacPackage @MacOSPackageParams + } + } + } + return $Counter +} + +function New-MsiInstaller { +<# + .SYNOPSIS + Build a WiX 6 SDK-style MSI installer. + WiX 6 is auto-resolved via the SDK-style .wixproj — no separate + wix.exe install is required. +#> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [string]$ProductType, + + [Parameter(Mandatory)] + [string]$RuntimeIdentifier, + + [Parameter(Mandatory)] + [string]$Arch, + + [Parameter(Mandatory)] + [hashtable]$VersionInfo, + + [Parameter(Mandatory)] + [string]$EditionName, + + [Parameter(Mandatory)] + [string]$PublishPath, + + [Parameter(Mandatory)] + [int]$Counter + ) + + [string]$WixArch = $Arch -ieq 'x64' ? 'x64' : 'arm64' + [string]$InstallerDir = Join-Path -Path $RepoRoot -ChildPath 'src' -AdditionalChildPath 'Installer', 'Msi', $ProductType + [string]$WixProj = Join-Path -Path $InstallerDir -ChildPath "$ProductType.wixproj" + + if (-not (Test-Path $WixProj)) { + Write-Warning "WiX project not found: $WixProj — skipping MSI for $ProductType" + return $Counter + } + + Write-Host "[$Counter] Building MSI: $EditionName ($WixArch)" + [string[]]$BuildArgs = @( + 'build' + $WixProj + '-c', 'Release' + "-p:Platform=$WixArch" + "-p:ProductVersion=$($VersionInfo.MajorMinorPatch)" + "-p:RuntimeIdentifier=$RuntimeIdentifier" + '-nologo' + ) + & dotnet @BuildArgs + if ($LASTEXITCODE -ne 0) { throw "WiX build failed for $EditionName (exit $LASTEXITCODE)" } + + # Move the MSI to the Publish folder + [string]$MsiOutputDir = Join-Path -Path $InstallerDir -ChildPath 'bin' -AdditionalChildPath 'Release' + [string]$MsiFile = Get-ChildItem -Path $MsiOutputDir -Filter '*.msi' -Recurse | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName + + if ([string]::IsNullOrWhiteSpace($MsiFile)) { + Write-Warning "MSI file not found in $MsiOutputDir" + return $Counter + 1 + } + + [string]$MsiDest = Join-Path -Path $PublishPath -ChildPath "$EditionName.msi" + # If this is a pre-release build, append the tag + if (-not [string]::IsNullOrWhiteSpace($VersionInfo.PreReleaseTag)) { + $MsiDest = Join-Path -Path $PublishPath -ChildPath "$EditionName.msi" + } + Move-Item -Path $MsiFile -Destination $MsiDest -Force -Verbose:$Verbose + + return $Counter + 1 +} + +function New-DebPackage { +<# + .SYNOPSIS + Create a .deb package with debconf, systemd service unit, and non-root + service user. The package installs to /opt/werkr// and creates + a werkr system user/group. +#> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [string]$ProductType, + + [Parameter(Mandatory)] + [string]$RuntimeIdentifier, + + [Parameter(Mandatory)] + [hashtable]$VersionInfo, + + [Parameter(Mandatory)] + [string]$EditionName, + + [Parameter(Mandatory)] + [string]$OutputPath, + + [Parameter(Mandatory)] + [string]$PublishPath, + + [Parameter(Mandatory)] + [int]$Counter + ) + + Write-Host "[$Counter] Building deb: $EditionName" + + [string]$ProductLower = $ProductType.ToLower() + # For ServerBundle the package name is werkr-server + [string]$PackageName = switch ($ProductType) { + 'ServerBundle' { 'werkr-server' } + 'Agent' { 'werkr-agent' } + default { "werkr-$ProductLower" } + } + [string]$ServiceName = $PackageName + [string]$InstallDir = "/opt/werkr/$ProductLower" + [string]$ConfigDir = "/etc/werkr" + + # Determine the main binary name + [string]$BinaryName = switch ($ProductType) { + 'ServerBundle' { 'Werkr.Server' } + 'Agent' { 'Werkr.Agent' } + default { "Werkr.$ProductType" } + } + + # Create staging structure + [string]$StagingDir = Join-Path -Path ([Path]::GetTempPath()) -ChildPath "werkr-deb-$EditionName" + if (Test-Path $StagingDir) { Remove-Item $StagingDir -Recurse -Force } + + # Directories + $null = New-Item -ItemType Directory -Force -Path (Join-Path $StagingDir 'DEBIAN') + $null = New-Item -ItemType Directory -Force -Path (Join-Path $StagingDir "opt/werkr/$ProductLower") + $null = New-Item -ItemType Directory -Force -Path (Join-Path $StagingDir 'etc/werkr') + $null = New-Item -ItemType Directory -Force -Path (Join-Path $StagingDir "lib/systemd/system") + + # ---- DEBIAN/control ---- + [string]$DebArch = $RuntimeIdentifier -match 'arm64' ? 'arm64' : 'amd64' + [string]$Description = switch ($ProductType) { + 'ServerBundle' { 'Werkr Server — Blazor UI + REST/gRPC API' } + 'Agent' { 'Werkr Agent — background agent with gRPC and PowerShell' } + default { "Werkr $ProductType" } + } + @" +Package: $PackageName +Version: $($VersionInfo.MajorMinorPatch) +Section: admin +Priority: optional +Architecture: $DebArch +Depends: libicu74 | libicu72 | libicu70, libssl3 | libssl3t64 +Maintainer: Werkr +Description: $Description +Homepage: https://werkr.app +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/control') -NoNewline + + # ---- DEBIAN/conffiles ---- + @" +/etc/werkr/appsettings.json +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/conffiles') -NoNewline + + # ---- DEBIAN/templates (debconf) ---- + if ($ProductType -ieq 'ServerBundle') { + @" +Template: $PackageName/config-path +Type: string +Default: $ConfigDir +Description: Configuration directory for $PackageName + The directory where $PackageName stores its configuration files. + The default is $ConfigDir. + +Template: $PackageName/install-components +Type: select +Choices: all, server-only, api-only +Default: all +Description: Which components to enable + Select which Werkr components to enable via systemd services. + Both Server and Api binaries are always installed. This controls + which systemd services are enabled on install. +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/templates') -NoNewline + } + else { + @" +Template: $PackageName/config-path +Type: string +Default: $ConfigDir +Description: Configuration directory for $PackageName + The directory where $PackageName stores its configuration files. + The default is $ConfigDir. +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/templates') -NoNewline + } + + # ---- DEBIAN/config (debconf) ---- + if ($ProductType -ieq 'ServerBundle') { + @" +#!/bin/sh +set -e +. /usr/share/debconf/confmodule +db_input medium $PackageName/config-path || true +db_input medium $PackageName/install-components || true +db_go || true +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/config') -NoNewline + } + else { + @" +#!/bin/sh +set -e +. /usr/share/debconf/confmodule +db_input medium $PackageName/config-path || true +db_go || true +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/config') -NoNewline + } + + # ---- DEBIAN/postinst ---- + if ($ProductType -ieq 'ServerBundle') { + @" +#!/bin/sh +set -e + +# Create werkr system user and group +if ! getent group werkr >/dev/null 2>&1; then + groupadd --system werkr +fi +if ! getent passwd werkr >/dev/null 2>&1; then + useradd --system --gid werkr --no-create-home --shell /usr/sbin/nologin werkr +fi + +# Ensure directories exist with correct ownership +mkdir -p $ConfigDir +mkdir -p /var/lib/werkr +mkdir -p /var/log/werkr +chown -R werkr:werkr $InstallDir +chown -R werkr:werkr $ConfigDir +chown -R werkr:werkr /var/lib/werkr +chown -R werkr:werkr /var/log/werkr + +# Create default config if it doesn't exist +if [ ! -f "$ConfigDir/appsettings.json" ]; then + echo '{}' > "$ConfigDir/appsettings.json" + chown werkr:werkr "$ConfigDir/appsettings.json" + chmod 640 "$ConfigDir/appsettings.json" +fi + +# debconf: read config path and install-components +. /usr/share/debconf/confmodule +db_get $PackageName/config-path || true +WERKR_CONFIG_PATH="\$RET" +db_get $PackageName/install-components || true +INSTALL_COMPONENTS="\$RET" + +# Enable services based on install-components selection +systemctl daemon-reload + +case "\$INSTALL_COMPONENTS" in + server-only) + mkdir -p /etc/systemd/system/werkr-server.service.d + cat > /etc/systemd/system/werkr-server.service.d/override.conf << EOF +[Service] +Environment=WERKR_CONFIG_PATH=\$WERKR_CONFIG_PATH +EOF + systemctl enable werkr-server.service || true + systemctl restart werkr-server.service || true + ;; + api-only) + mkdir -p /etc/systemd/system/werkr-api.service.d + cat > /etc/systemd/system/werkr-api.service.d/override.conf << EOF +[Service] +Environment=WERKR_CONFIG_PATH=\$WERKR_CONFIG_PATH +EOF + systemctl enable werkr-api.service || true + systemctl restart werkr-api.service || true + ;; + *) + # all — enable both + mkdir -p /etc/systemd/system/werkr-server.service.d + cat > /etc/systemd/system/werkr-server.service.d/override.conf << EOF +[Service] +Environment=WERKR_CONFIG_PATH=\$WERKR_CONFIG_PATH +EOF + mkdir -p /etc/systemd/system/werkr-api.service.d + cat > /etc/systemd/system/werkr-api.service.d/override.conf << EOF +[Service] +Environment=WERKR_CONFIG_PATH=\$WERKR_CONFIG_PATH +EOF + systemctl enable werkr-server.service || true + systemctl restart werkr-server.service || true + systemctl enable werkr-api.service || true + systemctl restart werkr-api.service || true + ;; +esac + +#DEBHELPER# +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/postinst') -NoNewline + } + else { + @" +#!/bin/sh +set -e + +# Create werkr system user and group +if ! getent group werkr >/dev/null 2>&1; then + groupadd --system werkr +fi +if ! getent passwd werkr >/dev/null 2>&1; then + useradd --system --gid werkr --no-create-home --shell /usr/sbin/nologin werkr +fi + +# Ensure directories exist with correct ownership +mkdir -p $ConfigDir +mkdir -p /var/lib/werkr +mkdir -p /var/log/werkr +chown -R werkr:werkr $InstallDir +chown -R werkr:werkr $ConfigDir +chown -R werkr:werkr /var/lib/werkr +chown -R werkr:werkr /var/log/werkr + +# Create default config if it doesn't exist +if [ ! -f "$ConfigDir/appsettings.json" ]; then + echo '{}' > "$ConfigDir/appsettings.json" + chown werkr:werkr "$ConfigDir/appsettings.json" + chmod 640 "$ConfigDir/appsettings.json" +fi + +# debconf: read config path +. /usr/share/debconf/confmodule +db_get $PackageName/config-path || true +WERKR_CONFIG_PATH="\$RET" + +# Update systemd environment override +mkdir -p /etc/systemd/system/$ServiceName.service.d +cat > /etc/systemd/system/$ServiceName.service.d/override.conf << EOF +[Service] +Environment=WERKR_CONFIG_PATH=\$WERKR_CONFIG_PATH +EOF + +# Enable and restart service +systemctl daemon-reload +systemctl enable $ServiceName.service || true +systemctl restart $ServiceName.service || true + +#DEBHELPER# +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/postinst') -NoNewline + } + + # ---- DEBIAN/prerm ---- + if ($ProductType -ieq 'ServerBundle') { + @" +#!/bin/sh +set -e +systemctl stop werkr-server.service || true +systemctl stop werkr-api.service || true +#DEBHELPER# +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/prerm') -NoNewline + } + else { + @" +#!/bin/sh +set -e +systemctl stop $ServiceName.service || true +#DEBHELPER# +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/prerm') -NoNewline + } + + # ---- DEBIAN/postrm ---- + if ($ProductType -ieq 'ServerBundle') { + @" +#!/bin/sh +set -e + +case "`$1" in + purge) + # Remove config, data, logs, and system user + rm -rf $ConfigDir + rm -rf /var/lib/werkr + rm -rf /var/log/werkr + rm -rf $InstallDir + rm -rf /etc/systemd/system/werkr-server.service.d + rm -rf /etc/systemd/system/werkr-api.service.d + userdel werkr 2>/dev/null || true + groupdel werkr 2>/dev/null || true + systemctl daemon-reload + ;; + remove) + systemctl daemon-reload + ;; +esac + +#DEBHELPER# +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/postrm') -NoNewline + } + else { + @" +#!/bin/sh +set -e + +case "`$1" in + purge) + # Remove config, data, logs, and system user + rm -rf $ConfigDir + rm -rf /var/lib/werkr + rm -rf /var/log/werkr + rm -rf $InstallDir + rm -rf /etc/systemd/system/$ServiceName.service.d + userdel werkr 2>/dev/null || true + groupdel werkr 2>/dev/null || true + systemctl daemon-reload + ;; + remove) + systemctl daemon-reload + ;; +esac + +#DEBHELPER# +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/postrm') -NoNewline + } + + # ---- systemd service unit(s) ---- + if ($ProductType -ieq 'ServerBundle') { + # Server service + @" +[Unit] +Description=Werkr Server — Blazor UI +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +ExecStart=$InstallDir/Werkr.Server +WorkingDirectory=$InstallDir +Restart=on-failure +RestartSec=10 +User=werkr +Group=werkr +Environment=DOTNET_ENVIRONMENT=Production +Environment=WERKR_CONFIG_PATH=$ConfigDir +Environment=WERKR_DATA_DIR=/var/lib/werkr +KillSignal=SIGTERM +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target +"@ | Set-Content -Path (Join-Path $StagingDir 'lib/systemd/system/werkr-server.service') -NoNewline + + # Api service + @" +[Unit] +Description=Werkr API — REST/gRPC API +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +ExecStart=$InstallDir/Werkr.Api +WorkingDirectory=$InstallDir +Restart=on-failure +RestartSec=10 +User=werkr +Group=werkr +Environment=DOTNET_ENVIRONMENT=Production +Environment=WERKR_CONFIG_PATH=$ConfigDir +Environment=WERKR_DATA_DIR=/var/lib/werkr +KillSignal=SIGTERM +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target +"@ | Set-Content -Path (Join-Path $StagingDir 'lib/systemd/system/werkr-api.service') -NoNewline + } + else { + @" +[Unit] +Description=$Description +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +ExecStart=$InstallDir/$BinaryName +WorkingDirectory=$InstallDir +Restart=on-failure +RestartSec=10 +User=werkr +Group=werkr +Environment=DOTNET_ENVIRONMENT=Production +Environment=WERKR_CONFIG_PATH=$ConfigDir +Environment=WERKR_DATA_DIR=/var/lib/werkr +KillSignal=SIGTERM +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target +"@ | Set-Content -Path (Join-Path $StagingDir "lib/systemd/system/$ServiceName.service") -NoNewline + } + + # ---- DEBIAN/rules ---- + @" +#!/usr/bin/make -f +%: +`tdh `$@ --with systemd +override_dh_shlibdeps: +override_dh_strip: +"@ | Set-Content -Path (Join-Path $StagingDir 'DEBIAN/rules') -NoNewline + + # Set executable permissions on maintainer scripts + if ($IsLinux -or $IsMacOS) { + chmod 755 (Join-Path $StagingDir 'DEBIAN/postinst') + chmod 755 (Join-Path $StagingDir 'DEBIAN/prerm') + chmod 755 (Join-Path $StagingDir 'DEBIAN/postrm') + chmod 755 (Join-Path $StagingDir 'DEBIAN/config') + chmod 755 (Join-Path $StagingDir 'DEBIAN/rules') + } + + # Copy published binaries + Copy-Item -Path (Join-Path $OutputPath '*') -Destination (Join-Path $StagingDir "opt/werkr/$ProductLower") -Recurse -Force + + # Build the .deb + [string]$DebFile = Join-Path -Path $PublishPath -ChildPath "$EditionName.deb" + & dpkg-deb --build --root-owner-group $StagingDir $DebFile + if ($LASTEXITCODE -ne 0) { throw "dpkg-deb failed for $EditionName (exit $LASTEXITCODE)" } + + # Cleanup staging + Remove-Item -Path $StagingDir -Recurse -Force -ErrorAction SilentlyContinue + + return $Counter + 1 +} + +function New-MacPackage { +<# + .SYNOPSIS + Create a macOS .app bundle. For ServerBundle, a launcher shell script + starts both Server and Api processes. +#> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [string]$ProductType, + + [Parameter(Mandatory)] + [hashtable]$VersionInfo, + + [Parameter(Mandatory)] + [string]$EditionName, + + [Parameter(Mandatory)] + [string]$OutputPath, + + [Parameter(Mandatory)] + [string]$PublishPath, + + [Parameter(Mandatory)] + [int]$Counter + ) + + Write-Host "[$Counter] Building macOS package: $EditionName" + + [string]$DisplayName = switch ($ProductType) { + 'ServerBundle' { 'Werkr Server' } + 'Agent' { 'Werkr Agent' } + default { "Werkr $ProductType" } + } + + [string]$BundleIdentifier = switch ($ProductType) { + 'ServerBundle' { 'app.werkr.server' } + 'Agent' { 'app.werkr.agent' } + default { "app.werkr.$($ProductType.ToLower())" } + } + + [string]$AppDir = Join-Path -Path $PublishPath -ChildPath "$DisplayName.app" + [string]$ContentsDir = Join-Path $AppDir 'Contents' + [string]$MacOSDir = Join-Path $ContentsDir 'MacOS' + [string]$ResourcesDir = Join-Path $ContentsDir 'Resources' + + # Create dirs + $null = New-Item -ItemType Directory -Force -Path $MacOSDir + $null = New-Item -ItemType Directory -Force -Path $ResourcesDir + $null = New-Item -ItemType Directory -Force -Path (Join-Path $ContentsDir 'en.lproj') + + # Info.plist + @" + + + + + CFBundleName + $DisplayName + CFBundleDisplayName + $DisplayName + CFBundleIdentifier + $BundleIdentifier + CFBundleVersion + $($VersionInfo.MajorMinorPatch) + CFBundleShortVersionString + $($VersionInfo.MajorMinorPatch) + CFBundlePackageType + APPL + CFBundleExecutable + launcher + LSMinimumSystemVersion + 15.0 + NSHumanReadableCopyright + Copyright © 2026 Werkr. All rights reserved. + LSBackgroundOnly + + + +"@ | Set-Content -Path (Join-Path $ContentsDir 'Info.plist') -NoNewline + + # Copy published binaries into MacOS/ + Copy-Item -Path (Join-Path $OutputPath '*') -Destination $MacOSDir -Recurse -Force + + # Create launcher script + if ($ProductType -ieq 'ServerBundle') { + # ServerBundle launcher starts both Server and Api + @" +#!/bin/bash +SCRIPT_DIR="`$(cd "`$(dirname "`$0")" && pwd)" +"`$SCRIPT_DIR/Werkr.Server" & +SERVER_PID=`$! +"`$SCRIPT_DIR/Werkr.Api" & +API_PID=`$! + +cleanup() { + kill `$SERVER_PID `$API_PID 2>/dev/null + wait `$SERVER_PID `$API_PID 2>/dev/null +} +trap cleanup EXIT INT TERM + +wait `$SERVER_PID `$API_PID +"@ | Set-Content -Path (Join-Path $MacOSDir 'launcher') -NoNewline + } + else { + # Single product launcher + [string]$BinaryName = "Werkr.$ProductType" + @" +#!/bin/bash +SCRIPT_DIR="`$(cd "`$(dirname "`$0")" && pwd)" +exec "`$SCRIPT_DIR/$BinaryName" +"@ | Set-Content -Path (Join-Path $MacOSDir 'launcher') -NoNewline + } + + # Make launcher executable + if ($IsLinux -or $IsMacOS) { + chmod +x (Join-Path $MacOSDir 'launcher') + } + + return $Counter + 1 +} + +function Compress-PublishArtifacts { +<# + .SYNOPSIS + Compress portable editions to .zip and .tar.gz, then remove the folders. +#> + [CmdletBinding()] + [OutputType([System.Void])] + param ( + [Parameter(Mandatory)] + [string]$PublishPath, + + [Parameter(Mandatory)] + [bool]$SkipCompression, + + [Parameter(Mandatory)] + [bool]$SkipTar + ) + + if ($SkipCompression) { return } + + Write-Host 'Compressing publish artifacts...' + foreach ($dir in (Get-ChildItem -Path $PublishPath -Directory)) { + # Skip .app bundles (they get compressed as a directory) + if ($dir.Name -like '*.app') { + [hashtable]$ZipParams = @{ + Path = $dir.FullName + DestinationPath = "$($dir.FullName).zip" + Force = $true + Verbose = $Verbose + } + Compress-Archive @ZipParams | Out-Null + Remove-Item -Path $dir.FullName -Recurse -Force -Verbose:$Verbose + continue + } + + [hashtable]$ZipParams = @{ + Path = $dir.FullName + DestinationPath = "$($dir.FullName).zip" + Force = $true + Verbose = $Verbose + } + Compress-Archive @ZipParams | Out-Null + + if (($IsLinux -or $IsMacOS) -and (-not $SkipTar)) { + tar -czvf "$($dir.FullName).tar.gz" -C $dir.Parent.FullName $dir.Name | Out-Null + } + Remove-Item -Path $dir.FullName -Recurse -Force -Verbose:$Verbose + } +} + +#endregion functions + + +#region Hard coded values + +# Minimum required dotnet SDK major version +[int]$DotNetVersion = 10 + +#endregion Hard coded values + + +#region Publish + +# Repo root is one level up from scripts/ +[string]$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') + +Push-Location -Path $RepoRoot +try { + # Assert prerequisites + Assert-DotnetInstalled -DotNetVersion $DotNetVersion -Verbose:$Verbose + Assert-DpkgDebInstalled -BuildDebInstallers $BuildDebInstallers -Verbose:$Verbose + Assert-TarInstalled -SkipTar $SkipTar -Verbose:$Verbose + + # Obtain version + [hashtable]$VersionInfo = Get-GitVersion -Verbose:$Verbose + Write-Host "Build version: $($VersionInfo.SemVer)" + + # Determine matrix + [string[]]$OperatingSystem = $Platform -ieq 'All' ? @('windows', 'linux', 'macos') : @($Platform) + [string[]]$CPUArch = $Architecture -ieq 'All' ? @('x64', 'arm64') : @($Architecture) + + # Product types: + # ServerBundle = publishes both Werkr.Server + Werkr.Api into a single package + # Agent = publishes Werkr.Agent standalone + [string[]]$ProductTypes = $Application -ieq 'All' ? @('ServerBundle', 'Agent') : @($Application) + + # Create output directory + [string]$PublishPath = Join-Path -Path $RepoRoot -ChildPath 'Publish' + $PublishPath = New-Item -Path $PublishPath -ItemType Directory -Force -Verbose:$Verbose + + [int]$Counter = 1 + + foreach ($ProductType in $ProductTypes) { + # Determine which projects to publish + [string[]]$ProjectPaths = switch ($ProductType) { + 'ServerBundle' { + @( + (Join-Path $RepoRoot 'src' 'Werkr.Server' 'Werkr.Server.csproj'), + (Join-Path $RepoRoot 'src' 'Werkr.Api' 'Werkr.Api.csproj') + ) + } + 'Agent' { + @( + (Join-Path $RepoRoot 'src' 'Werkr.Agent' 'Werkr.Agent.csproj') + ) + } + default { + throw "Unknown product type: $ProductType" + } + } + + foreach ($OS in $OperatingSystem) { + foreach ($Arch in $CPUArch) { + [string]$RuntimeIdentifier = switch ($OS) { + 'windows' { "win-$Arch" } + 'macos' { "osx-$Arch" } + default { "$OS-$Arch" } + } + [string]$EditionName = "Werkr.$ProductType.$($VersionInfo.SemVer).$RuntimeIdentifier" + [string]$OutputPath = Join-Path -Path $PublishPath -ChildPath $EditionName + + if ($OS -eq 'windows') { + foreach ($ProjPath in $ProjectPaths) { + [hashtable]$GrpcParams = @{ + ProjectPath = $ProjPath + RuntimeIdentifier = $RuntimeIdentifier + Arch = $Arch + Verbose = $Verbose + } + Set-GrpcToolsArm64Directory @GrpcParams + } + } + + # Publish all projects for this product type into the same output path + foreach ($ProjPath in $ProjectPaths) { + [hashtable]$ExeParams = @{ + OutputPath = $OutputPath + ProjectPath = $ProjPath + RuntimeIdentifier = $RuntimeIdentifier + VersionInfo = $VersionInfo + Counter = $Counter + Verbose = $Verbose + } + $Counter = New-Executable @ExeParams + } + + # Build installers + [hashtable]$InstallerParams = @{ + OS = $OS + BuildMsiInstallers = $BuildMsiInstallers + BuildDebInstallers = $BuildDebInstallers + BuildMacOSPackage = $BuildMacOSPackage + ProductType = $ProductType + RuntimeIdentifier = $RuntimeIdentifier + Arch = $Arch + VersionInfo = $VersionInfo + EditionName = $EditionName + OutputPath = $OutputPath + PublishPath = $PublishPath + Counter = $Counter + Verbose = $Verbose + } + $Counter = Build-Installer @InstallerParams + } + } + } + + # Compress portable artifacts + [hashtable]$CompressionParams = @{ + PublishPath = $PublishPath + SkipCompression = $SkipCompression + SkipTar = $SkipTar + Verbose = $Verbose + } + Compress-PublishArtifacts @CompressionParams + + Write-Host "`nPublish complete. Artifacts: $PublishPath" -ForegroundColor Green +} finally { + Pop-Location +} + +#endregion Publish diff --git a/src/Installer/Msi/Agent/Agent.wixproj b/src/Installer/Msi/Agent/Agent.wixproj new file mode 100644 index 0000000..3370d41 --- /dev/null +++ b/src/Installer/Msi/Agent/Agent.wixproj @@ -0,0 +1,59 @@ + + + 0.0.0.1 + Werkr.$(MSBuildProjectName).$(BuildVersion).$(RuntimeIdentifier) + true + + + + + + + + + + + + + + ..\..\..\Werkr.Agent\bin\$(Configuration)\net10.0 + ..\..\..\Werkr.Agent\bin\$(Configuration)\net10.0\$(RuntimeIdentifier) + ..\CustomActions\bin\$(Configuration)\net481\$(RuntimeIdentifier) + + + ..\..\..\Werkr.Agent\bin\$(Configuration) + ..\..\..\Werkr.Agent\bin\$(Configuration)\net10.0 + ..\CustomActions\bin\$(Configuration)\net481 + + + + + + + + + + + + + + + + + + + + + + + Version=$(BuildVersion);ServiceDirPath=$(ServiceDirPath) + + + + $(NoWarn);NU1701;MSB3246 + + + diff --git a/src/Installer/Msi/Agent/Agent.wxs b/src/Installer/Msi/Agent/Agent.wxs new file mode 100644 index 0000000..6b8f163 --- /dev/null +++ b/src/Installer/Msi/Agent/Agent.wxs @@ -0,0 +1,411 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + a6GaN#iG2pY;nP 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 I_|6IZfB6F +/// Fake that permits every path. +/// Used by handler unit tests where path security is not under test. +/// +internal sealed class AllowAllPathValidator : IPathAllowlistValidator { + + public void ValidatePath( string path ) { } + + public void ValidatePaths( params string[] paths ) { } + + public bool IsPathAllowed( string path ) => true; +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/AllowPrefixValidator.cs b/src/Test/Werkr.Tests.Agent/Helpers/AllowPrefixValidator.cs new file mode 100644 index 0000000..3731f46 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/AllowPrefixValidator.cs @@ -0,0 +1,41 @@ +using Werkr.Core.Security; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// Fake that only permits paths +/// under specified prefixes. Used for testing allowlist enforcement +/// in tests. +/// +internal sealed class AllowPrefixValidator : IPathAllowlistValidator { + + private readonly string[] _allowedPrefixes; + + public AllowPrefixValidator( params string[] allowedPrefixes ) { + _allowedPrefixes = allowedPrefixes; + } + + public void ValidatePath( string path ) { + if (!IsPathAllowed( path )) { + throw new UnauthorizedAccessException( + $"Path '{path}' is outside the configured allowlist." ); + } + } + + public void ValidatePaths( params string[] paths ) { + foreach (string path in paths) { + ValidatePath( path ); + } + } + + public bool IsPathAllowed( string path ) { + string fullPath = Path.GetFullPath( path ); + foreach (string prefix in _allowedPrefixes) { + string normalizedPrefix = Path.GetFullPath( prefix ); + if (fullPath.StartsWith( normalizedPrefix, StringComparison.OrdinalIgnoreCase )) { + return true; + } + } + return false; + } +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/DenyAllPathValidator.cs b/src/Test/Werkr.Tests.Agent/Helpers/DenyAllPathValidator.cs new file mode 100644 index 0000000..7e052bc --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/DenyAllPathValidator.cs @@ -0,0 +1,21 @@ +using Werkr.Core.Security; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// Fake that denies every path. +/// Used to test that handlers properly propagate allowlist rejections. +/// +internal sealed class DenyAllPathValidator : IPathAllowlistValidator { + + public void ValidatePath( string path ) => + throw new UnauthorizedAccessException( $"Path '{path}' is outside the configured allowlist." ); + + public void ValidatePaths( params string[] paths ) { + foreach (string path in paths) { + ValidatePath( path ); + } + } + + public bool IsPathAllowed( string path ) => false; +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/FailHandler.cs b/src/Test/Werkr.Tests.Agent/Helpers/FailHandler.cs new file mode 100644 index 0000000..6503507 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/FailHandler.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Microsoft.Extensions.Logging; + +using Werkr.Core.Communication; +using Werkr.Core.Operators; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// Fake action handler that always fails (returns Success = false, no throw). +/// +internal sealed class FailHandler : IActionHandler { + + public FailHandler( string action = "FailAction" ) { + Action = action; + } + + public string Action { get; } + + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Error, "Action failed" ), cancellationToken ); + return new ActionOperatorResult( Success: false ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/MockServerStreamWriter.cs b/src/Test/Werkr.Tests.Agent/Helpers/MockServerStreamWriter.cs new file mode 100644 index 0000000..1282bac --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/MockServerStreamWriter.cs @@ -0,0 +1,29 @@ +using Grpc.Core; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// Mock that collects written messages for assertion. +/// +internal sealed class MockServerStreamWriter : IServerStreamWriter { + private readonly List _messages = []; + + /// Gets the messages written to this stream. + public IReadOnlyList Messages => _messages; + + /// + public WriteOptions? WriteOptions { get; set; } + + /// + public Task WriteAsync( T message ) { + _messages.Add( message ); + return Task.CompletedTask; + } + + /// + public Task WriteAsync( T message, CancellationToken cancellationToken ) { + cancellationToken.ThrowIfCancellationRequested( ); + _messages.Add( message ); + return Task.CompletedTask; + } +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/SlowHandler.cs b/src/Test/Werkr.Tests.Agent/Helpers/SlowHandler.cs new file mode 100644 index 0000000..6356612 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/SlowHandler.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Microsoft.Extensions.Logging; + +using Werkr.Core.Communication; +using Werkr.Core.Operators; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// Fake action handler that delays forever (until cancelled). +/// Used for timeout and cancellation tests. +/// +internal sealed class SlowHandler : IActionHandler { + + public SlowHandler( string action = "SlowAction" ) { + Action = action; + } + + public string Action { get; } + + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, "Starting slow action..." ), cancellationToken ); + // Wait indefinitely until cancelled + await Task.Delay( Timeout.Infinite, cancellationToken ); + return new ActionOperatorResult( Success: true ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs b/src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs new file mode 100644 index 0000000..7b7a628 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Microsoft.Extensions.Logging; + +using Werkr.Core.Communication; +using Werkr.Core.Operators; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// Fake action handler that always succeeds. Used by ActionOperatorTests. +/// +internal sealed class SuccessHandler : IActionHandler { + + public SuccessHandler( string action = "TestAction" ) { + Action = action; + } + + public string Action { get; } + + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, "Success" ), cancellationToken ); + return new ActionOperatorResult( Success: true ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/TestActionDescriptor.cs b/src/Test/Werkr.Tests.Agent/Helpers/TestActionDescriptor.cs new file mode 100644 index 0000000..79ceeb8 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/TestActionDescriptor.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +using Werkr.Common.Models.Actions; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// Helper for building instances in tests +/// with type-safe parameter serialization. +/// +internal static class TestActionDescriptor { + + private static readonly JsonSerializerOptions s_options = new( ) { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// + /// Serializes an object to a using camelCase naming. + /// Shared by all handler tests to avoid creating per-call serializer options. + /// + public static JsonElement Serialize( T value ) => + JsonSerializer.SerializeToElement( value, s_options ); + + /// + /// Creates an with serialized parameters. + /// + public static ActionDescriptor Create( string action, T parameters ) => + new( ) { + Action = action, + Parameters = Serialize( parameters ), + }; + + /// + /// Creates an with an empty JSON object parameter. + /// + public static ActionDescriptor Create( string action ) => + new( ) { + Action = action, + Parameters = JsonSerializer.SerializeToElement( new { } ), + }; +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/TestFilePathResolver.cs b/src/Test/Werkr.Tests.Agent/Helpers/TestFilePathResolver.cs new file mode 100644 index 0000000..c1c8609 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/TestFilePathResolver.cs @@ -0,0 +1,21 @@ +using Werkr.Agent.Security; +using Werkr.Core.Security; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// Creates an backed by +/// for handler tests that need path resolution but not enforcement. +/// +internal static class TestFilePathResolver { + + /// Gets a resolver that allows all paths. + public static IFilePathResolver AllowAll { get; } = new FilePathResolver( new AllowAllPathValidator( ) ); + + /// Gets a resolver that denies all paths. + public static IFilePathResolver DenyAll { get; } = new FilePathResolver( new DenyAllPathValidator( ) ); + + /// Creates a resolver that only allows paths under the given prefixes. + public static IFilePathResolver AllowPrefixes( params string[] prefixes ) => + new FilePathResolver( new AllowPrefixValidator( prefixes ) ); +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/TestServerCallContext.cs b/src/Test/Werkr.Tests.Agent/Helpers/TestServerCallContext.cs new file mode 100644 index 0000000..069ac40 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/TestServerCallContext.cs @@ -0,0 +1,47 @@ +using Grpc.Core; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// Minimal stub for unit-testing gRPC interceptors. +/// +internal sealed class TestServerCallContext : ServerCallContext { + private readonly Metadata _requestHeaders; + private readonly CancellationToken _cancellationToken; + private readonly Dictionary _userState = []; + + private TestServerCallContext( Metadata requestHeaders, CancellationToken cancellationToken ) { + _requestHeaders = requestHeaders; + _cancellationToken = cancellationToken; + } + + public static TestServerCallContext Create( + Metadata? requestHeaders = null, + CancellationToken cancellationToken = default ) { + return new TestServerCallContext( requestHeaders ?? [], cancellationToken ); + } + + protected override string MethodCore => "/test/Method"; + protected override string HostCore => "localhost"; + protected override string PeerCore => "ipv4:127.0.0.1:12345"; + protected override DateTime DeadlineCore => DateTime.MaxValue; + protected override Metadata RequestHeadersCore => _requestHeaders; + protected override CancellationToken CancellationTokenCore => _cancellationToken; + protected override Metadata ResponseTrailersCore => []; + protected override Status StatusCore { get; set; } + protected override WriteOptions? WriteOptionsCore { get; set; } + protected override AuthContext AuthContextCore => new( string.Empty, [] ); + + /// Exposes the user-state dictionary that interceptors write to. + public IDictionary ExposedUserState => _userState; + + protected override IDictionary UserStateCore => _userState; + + protected override ContextPropagationToken CreatePropagationTokenCore( ContextPropagationOptions? options ) { + throw new NotImplementedException( ); + } + + protected override Task WriteResponseHeadersAsyncCore( Metadata responseHeaders ) { + return Task.CompletedTask; + } +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/ThrowHandler.cs b/src/Test/Werkr.Tests.Agent/Helpers/ThrowHandler.cs new file mode 100644 index 0000000..ac83aeb --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/ThrowHandler.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Core.Communication; +using Werkr.Core.Operators; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// Fake action handler that always throws an exception. +/// +internal sealed class ThrowHandler : IActionHandler { + + public ThrowHandler( string action = "ThrowAction" ) { + Action = action; + } + + public string Action { get; } + + public Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + throw new InvalidOperationException( "Simulated handler failure." ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Interceptors/BearerTokenInterceptorTests.cs b/src/Test/Werkr.Tests.Agent/Interceptors/BearerTokenInterceptorTests.cs new file mode 100644 index 0000000..c6cc796 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Interceptors/BearerTokenInterceptorTests.cs @@ -0,0 +1,230 @@ +using Grpc.Core; + +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Interceptors; +using Werkr.Common.Models; +using Werkr.Core.Cryptography; +using Werkr.Core.Cryptography.KeyInfo; +using Werkr.Data; +using Werkr.Data.Entities.Registration; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Interceptors; + +[TestClass] +public class BearerTokenInterceptorTests { + private SqliteConnection _connection = null!; + private ServiceProvider _serviceProvider = null!; + private BearerTokenInterceptor _interceptor = null!; + + /// Raw API key value that matches the stored hash. + private string _rawToken = null!; + private Guid _connectionId; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + ServiceCollection services = new( ); + _ = services.AddDbContext( + b => b.UseSqlite( _connection ), ServiceLifetime.Scoped ); + _ = services.AddScoped( sp => sp.GetRequiredService( ) ); + + _serviceProvider = services.BuildServiceProvider( ); + + // Ensure schema is created + using IServiceScope initScope = _serviceProvider.CreateScope( ); + WerkrDbContext initDb = initScope.ServiceProvider.GetRequiredService( ); + _ = initDb.Database.EnsureCreated( ); + + // Seed a valid agent-side connection (IsServer = false) + _rawToken = Convert.ToBase64String( EncryptionProvider.GenerateRandomBytes( 32 ) ); + string tokenHash = EncryptionProvider.HashSHA512String( _rawToken ); + RSAKeyPair keys = EncryptionProvider.GenerateRSAKeyPair( ); + + RegisteredConnection conn = new( ) { + ConnectionName = "TestAgent", + RemoteUrl = "https://localhost:5000", + LocalPublicKey = keys.PublicKey, + LocalPrivateKey = keys.PrivateKey, + RemotePublicKey = keys.PublicKey, + OutboundApiKey = "outbound", + InboundApiKeyHash = tokenHash, + SharedKey = EncryptionProvider.GenerateRandomBytes( 32 ), + IsServer = false, + Status = ConnectionStatus.Connected, + }; + + _ = initDb.RegisteredConnections.Add( conn ); + _ = initDb.SaveChanges( ); + _connectionId = conn.Id; + + _interceptor = new BearerTokenInterceptor( + _serviceProvider.GetRequiredService( ), + NullLogger.Instance ); + } + + [TestCleanup] + public void TestCleanup( ) { + _serviceProvider.Dispose( ); + _connection.Dispose( ); + } + + [TestMethod] + public async Task ValidToken_InvokesContinuation( ) { + Metadata headers = CreateValidHeaders( ); + TestServerCallContext ctx = TestServerCallContext.Create( headers, TestContext.CancellationToken ); + bool continuationCalled = false; + + _ = await _interceptor.UnaryServerHandler( + "request", + ctx, + ( req, context ) => { + continuationCalled = true; + return Task.FromResult( "response" ); + } ); + + Assert.IsTrue( continuationCalled ); + } + + [TestMethod] + public async Task ValidToken_StoresConnectionInUserState( ) { + Metadata headers = CreateValidHeaders( ); + TestServerCallContext ctx = TestServerCallContext.Create( headers, TestContext.CancellationToken ); + + _ = await _interceptor.UnaryServerHandler( + "request", + ctx, + ( req, context ) => Task.FromResult( "response" ) ); + + Assert.IsTrue( ctx.ExposedUserState.ContainsKey( "Connection" ) ); + RegisteredConnection resolved = (RegisteredConnection)ctx.ExposedUserState["Connection"]; + Assert.AreEqual( _connectionId, resolved.Id ); + } + + [TestMethod] + public async Task MissingAuthHeader_ThrowsUnauthenticated( ) { + Metadata headers = new( ) { + { "x-werkr-connection-id", _connectionId.ToString( ) }, + }; + TestServerCallContext ctx = TestServerCallContext.Create( headers, TestContext.CancellationToken ); + + RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => + await _interceptor.UnaryServerHandler( + "request", ctx, ( r, c ) => Task.FromResult( "ok" ) ) ); + + Assert.AreEqual( StatusCode.Unauthenticated, ex.StatusCode ); + } + + [TestMethod] + public async Task MissingConnectionIdHeader_ThrowsUnauthenticated( ) { + Metadata headers = new( ) { + { "authorization", $"Bearer {_rawToken}" }, + }; + TestServerCallContext ctx = TestServerCallContext.Create( headers, TestContext.CancellationToken ); + + RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => + await _interceptor.UnaryServerHandler( + "request", ctx, ( r, c ) => Task.FromResult( "ok" ) ) ); + + Assert.AreEqual( StatusCode.Unauthenticated, ex.StatusCode ); + } + + [TestMethod] + public async Task InvalidToken_ThrowsUnauthenticated( ) { + Metadata headers = new( ) { + { "authorization", "Bearer wrong-token" }, + { "x-werkr-connection-id", _connectionId.ToString( ) }, + }; + TestServerCallContext ctx = TestServerCallContext.Create( headers, TestContext.CancellationToken ); + + RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => + await _interceptor.UnaryServerHandler( + "request", ctx, ( r, c ) => Task.FromResult( "ok" ) ) ); + + Assert.AreEqual( StatusCode.Unauthenticated, ex.StatusCode ); + } + + [TestMethod] + public async Task RevokedConnection_ThrowsUnauthenticated( ) { + // Revoke the connection + using IServiceScope scope = _serviceProvider.CreateScope( ); + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + RegisteredConnection? conn = await db.RegisteredConnections.FindAsync( + [ _connectionId ], TestContext.CancellationToken ); + conn!.Status = ConnectionStatus.Revoked; + _ = await db.SaveChangesAsync( TestContext.CancellationToken ); + + Metadata headers = CreateValidHeaders( ); + TestServerCallContext ctx = TestServerCallContext.Create( headers, TestContext.CancellationToken ); + + RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => + await _interceptor.UnaryServerHandler( + "request", ctx, ( r, c ) => Task.FromResult( "ok" ) ) ); + + Assert.AreEqual( StatusCode.Unauthenticated, ex.StatusCode ); + } + + [TestMethod] + public async Task NonExistentConnection_ThrowsUnauthenticated( ) { + Metadata headers = new( ) { + { "authorization", $"Bearer {_rawToken}" }, + { "x-werkr-connection-id", Guid.NewGuid( ).ToString( ) }, + }; + TestServerCallContext ctx = TestServerCallContext.Create( headers, TestContext.CancellationToken ); + + RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => + await _interceptor.UnaryServerHandler( + "request", ctx, ( r, c ) => Task.FromResult( "ok" ) ) ); + + Assert.AreEqual( StatusCode.Unauthenticated, ex.StatusCode ); + } + + [TestMethod] + public async Task ValidToken_UpdatesLastSeen( ) { + Metadata headers = CreateValidHeaders( ); + TestServerCallContext ctx = TestServerCallContext.Create( headers, TestContext.CancellationToken ); + + _ = await _interceptor.UnaryServerHandler( + "request", ctx, ( r, c ) => Task.FromResult( "ok" ) ); + + // Verify LastSeen was set + using IServiceScope scope = _serviceProvider.CreateScope( ); + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + RegisteredConnection? conn = await db.RegisteredConnections.FindAsync( + [ _connectionId ], TestContext.CancellationToken ); + Assert.IsNotNull( conn!.LastSeen ); + Assert.IsGreaterThanOrEqualTo( DateTime.UtcNow.AddSeconds( -5 ), conn.LastSeen.Value ); + } + + [TestMethod] + public async Task CallId_StoredInUserState( ) { + Guid callId = Guid.NewGuid( ); + Metadata headers = new( ) { + { "authorization", $"Bearer {_rawToken}" }, + { "x-werkr-connection-id", _connectionId.ToString( ) }, + { "x-werkr-call-id", callId.ToString( ) }, + }; + TestServerCallContext ctx = TestServerCallContext.Create( headers, TestContext.CancellationToken ); + + _ = await _interceptor.UnaryServerHandler( + "request", ctx, ( r, c ) => Task.FromResult( "ok" ) ); + + Assert.IsTrue( ctx.ExposedUserState.ContainsKey( "CallId" ) ); + Assert.AreEqual( callId.ToString( ), ctx.ExposedUserState["CallId"] ); + } + + private Metadata CreateValidHeaders( ) { + return [ + new Metadata.Entry( "authorization", $"Bearer {_rawToken}" ), + new Metadata.Entry( "x-werkr-connection-id", _connectionId.ToString( ) ), + ]; + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/ActionOperatorTests.cs b/src/Test/Werkr.Tests.Agent/Operators/ActionOperatorTests.cs new file mode 100644 index 0000000..457741b --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/ActionOperatorTests.cs @@ -0,0 +1,284 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +using Werkr.Agent.Operators; +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators; + +[TestClass] +public class ActionOperatorTests { + + public TestContext TestContext { get; set; } = null!; + + private static IOptionsMonitor DefaultOptions( TimeSpan? timeout = null ) { + ActionOperatorConfiguration config = new( ) { + DefaultTimeout = timeout ?? TimeSpan.FromHours( 1 ), + }; + return new TestOptionsMonitor( config ); + } + + private static ActionOperator CreateOperator( + IActionHandler[] handlers, + IOptionsMonitor? options = null, + IEnumerable? expectedActions = null ) { + return new ActionOperator( + handlers, + options ?? DefaultOptions( ), + NullLogger.Instance, + expectedActions ?? [] ); + } + + // ──────── Construction / Validation ──────── + + [TestMethod] + public void Constructor_ValidHandlers_Succeeds( ) { + IActionHandler[] handlers = [new SuccessHandler( "A" ), new SuccessHandler( "B" )]; + + ActionOperator op = CreateOperator( handlers ); + + Assert.IsNotNull( op ); + } + + [TestMethod] + public void Constructor_DuplicateHandlers_ThrowsInvalidOperation( ) { + IActionHandler[] handlers = [new SuccessHandler( "A" ), new SuccessHandler( "A" )]; + + _ = Assert.ThrowsExactly( + ( ) => CreateOperator( handlers ) ); + } + + [TestMethod] + public void Constructor_MissingExpectedActions_ThrowsInvalidOperation( ) { + IActionHandler[] handlers = [new SuccessHandler( "A" )]; + + _ = Assert.ThrowsExactly( + ( ) => new ActionOperator( + handlers, + DefaultOptions( ), + NullLogger.Instance, + expectedActions: ["A", "B", "C"] ) ); + } + + [TestMethod] + public void Constructor_EmptyExpectedActions_SkipsValidation( ) { + IActionHandler[] handlers = [new SuccessHandler( "OnlyOne" )]; + + // Should not throw even though only 1 handler and no DefaultExpectedActions + ActionOperator op = CreateOperator( handlers, expectedActions: [] ); + + Assert.IsNotNull( op ); + } + + [TestMethod] + public void Constructor_NullExpectedActions_UsesDefaultList( ) { + // With null expectedActions, it uses DefaultExpectedActions which requires 11 handlers + IActionHandler[] handlers = [new SuccessHandler( "A" )]; + + _ = Assert.ThrowsExactly( + ( ) => new ActionOperator( + handlers, + DefaultOptions( ), + NullLogger.Instance, + expectedActions: null ) ); + } + + // ──────── Execute — Success ──────── + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Execute_SuccessHandler_ReturnsSuccess( ) { + SuccessHandler handler = new( "TestAction" ); + ActionOperator op = CreateOperator( [handler] ); + + ActionDescriptor descriptor = TestActionDescriptor.Create( "TestAction" ); + OperatorExecution execution = op.Execute( descriptor, TestContext.CancellationToken ); + + List outputs = []; + await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + IOperatorResult result = await execution.Result; + + Assert.IsTrue( result is ActionOperatorResult { Success: true } ); + Assert.IsNotEmpty( outputs ); + } + + // ──────── Execute — Failure ──────── + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Execute_FailHandler_ReturnsFailure( ) { + FailHandler handler = new( "FailAction" ); + ActionOperator op = CreateOperator( [handler] ); + + ActionDescriptor descriptor = TestActionDescriptor.Create( "FailAction" ); + OperatorExecution execution = op.Execute( descriptor, TestContext.CancellationToken ); + + await foreach (OperatorOutput _ in execution.Output.WithCancellation( TestContext.CancellationToken )) { } + + IOperatorResult result = await execution.Result; + + Assert.IsTrue( result is ActionOperatorResult { Success: false } ); + } + + // ──────── Execute — Exception ──────── + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Execute_ThrowHandler_ReturnsFailureWithException( ) { + ThrowHandler handler = new( "ThrowAction" ); + ActionOperator op = CreateOperator( [handler] ); + + ActionDescriptor descriptor = TestActionDescriptor.Create( "ThrowAction" ); + OperatorExecution execution = op.Execute( descriptor, TestContext.CancellationToken ); + + await foreach (OperatorOutput _ in execution.Output.WithCancellation( TestContext.CancellationToken )) { } + + IOperatorResult result = await execution.Result; + + Assert.IsTrue( result is ActionOperatorResult { Success: false } ); + ActionOperatorResult actionResult = (ActionOperatorResult) result; + Assert.IsNotNull( actionResult.Exception ); + _ = Assert.IsInstanceOfType( actionResult.Exception ); + } + + // ──────── Execute — Unknown Action ──────── + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Execute_UnknownAction_ReturnsFailure( ) { + SuccessHandler handler = new( "KnownAction" ); + ActionOperator op = CreateOperator( [handler] ); + + ActionDescriptor descriptor = TestActionDescriptor.Create( "UnknownAction" ); + OperatorExecution execution = op.Execute( descriptor, TestContext.CancellationToken ); + + List outputs = []; + await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + IOperatorResult result = await execution.Result; + + Assert.IsTrue( result is ActionOperatorResult { Success: false } ); + Assert.Contains( + o => o.LogLevel == "Error" && o.Message.Contains( "No handler registered" ), + outputs, "Expected error message about missing handler." ); + } + + // ──────── Execute — Timeout ──────── + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Execute_Timeout_ReturnsFailure( ) { + SlowHandler handler = new( "SlowAction" ); + ActionOperator op = CreateOperator( + [handler], + options: DefaultOptions( timeout: TimeSpan.FromMilliseconds( 200 ) ) ); + + ActionDescriptor descriptor = TestActionDescriptor.Create( "SlowAction" ); + OperatorExecution execution = op.Execute( descriptor, TestContext.CancellationToken ); + + List outputs = []; + await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + IOperatorResult result = await execution.Result; + + Assert.IsTrue( result is ActionOperatorResult { Success: false } ); + Assert.Contains( + o => o.LogLevel == "Warning" && o.Message.Contains( "timed out" ), + outputs, "Expected timeout warning in output." ); + } + + // ──────── Execute — Cancellation ──────── + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Execute_Cancellation_ReturnsFailure( ) { + SlowHandler handler = new( "SlowAction" ); + ActionOperator op = CreateOperator( + [handler], + options: DefaultOptions( timeout: null ) ); + + using CancellationTokenSource cts = new( TimeSpan.FromMilliseconds( 200 ) ); + + ActionDescriptor descriptor = TestActionDescriptor.Create( "SlowAction" ); + OperatorExecution execution = op.Execute( descriptor, cts.Token ); + + List outputs = []; + try { + await foreach (OperatorOutput output in execution.Output.WithCancellation( cts.Token )) { + outputs.Add( output ); + } + } catch (OperationCanceledException) { + // Expected + } + + IOperatorResult result = await execution.Result; + + Assert.IsTrue( result is ActionOperatorResult { Success: false } ); + } + + // ──────── Execute — Case-insensitive dispatch ──────── + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Execute_CaseInsensitiveActionName_DispatchesCorrectly( ) { + SuccessHandler handler = new( "TestAction" ); + ActionOperator op = CreateOperator( [handler] ); + + // Use different casing than registered + ActionDescriptor descriptor = TestActionDescriptor.Create( "testaction" ); + OperatorExecution execution = op.Execute( descriptor, TestContext.CancellationToken ); + + await foreach (OperatorOutput _ in execution.Output.WithCancellation( TestContext.CancellationToken )) { } + + IOperatorResult result = await execution.Result; + + Assert.IsTrue( result is ActionOperatorResult { Success: true } ); + } + + // ──────── Execute — Null timeout means no timeout ──────── + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Execute_NullTimeout_NoTimeoutApplied( ) { + SuccessHandler handler = new( "TestAction" ); + ActionOperator op = CreateOperator( + [handler], + options: DefaultOptions( timeout: null ) ); + + ActionDescriptor descriptor = TestActionDescriptor.Create( "TestAction" ); + OperatorExecution execution = op.Execute( descriptor, TestContext.CancellationToken ); + + await foreach (OperatorOutput _ in execution.Output.WithCancellation( TestContext.CancellationToken )) { } + + IOperatorResult result = await execution.Result; + + Assert.IsTrue( result is ActionOperatorResult { Success: true } ); + } + + /// + /// Simple implementation for tests. + /// + private sealed class TestOptionsMonitor : IOptionsMonitor { + + public TestOptionsMonitor( T currentValue ) { + CurrentValue = currentValue; + } + + public T CurrentValue { get; } + + public T Get( string? name ) => CurrentValue; + + public IDisposable? OnChange( Action listener ) => null; + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/ClearContentHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/ClearContentHandlerTests.cs new file mode 100644 index 0000000..b2c2198 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/ClearContentHandlerTests.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +[TestClass] +public class ClearContentHandlerTests { + + private string _tempDir = null!; + private ClearContentHandler _handler = null!; + private Channel _channel = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new ClearContentHandler( TestFilePathResolver.AllowAll, NullLogger.Instance ); + _channel = Channel.CreateUnbounded( ); + } + + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ClearContent_Succeeds( ) { + string path = Path.Combine( _tempDir, "file.txt" ); + await File.WriteAllTextAsync( path, "hello world", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new ClearContentParameters { Path = path } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( string.Empty, await File.ReadAllTextAsync( path, TestContext.CancellationToken ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ClearContent_FileNotFound_ReturnsFailure( ) { + string path = Path.Combine( _tempDir, "missing.txt" ); + + JsonElement parameters = Serialize( new ClearContentParameters { Path = path } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ClearContent_AlreadyEmpty_StillSucceeds( ) { + string path = Path.Combine( _tempDir, "empty.txt" ); + await File.WriteAllTextAsync( path, string.Empty, TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new ClearContentParameters { Path = path } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/CopyFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/CopyFileHandlerTests.cs new file mode 100644 index 0000000..bcecaa2 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/CopyFileHandlerTests.cs @@ -0,0 +1,139 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +[TestClass] +public class CopyFileHandlerTests { + + private string _tempDir = null!; + private CopyFileHandler _handler = null!; + private Channel _channel = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new CopyFileHandler( TestFilePathResolver.AllowAll, NullLogger.Instance ); + _channel = Channel.CreateUnbounded( ); + } + + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CopySingleFile_Succeeds( ) { + string src = Path.Combine( _tempDir, "source.txt" ); + string dest = Path.Combine( _tempDir, "dest.txt" ); + await File.WriteAllTextAsync( src, "hello", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new CopyFileParameters { Source = src, Destination = dest } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( dest ) ); + Assert.AreEqual( "hello", await File.ReadAllTextAsync( dest, TestContext.CancellationToken ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CopyWildcard_CopiesMatchingFiles( ) { + string src1 = Path.Combine( _tempDir, "a.txt" ); + string src2 = Path.Combine( _tempDir, "b.txt" ); + string destDir = Path.Combine( _tempDir, "out" ); + _ = Directory.CreateDirectory( destDir ); + await File.WriteAllTextAsync( src1, "a", TestContext.CancellationToken ); + await File.WriteAllTextAsync( src2, "b", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new CopyFileParameters { + Source = Path.Combine( _tempDir, "*.txt" ), + Destination = destDir + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( Path.Combine( destDir, "a.txt" ) ) ); + Assert.IsTrue( File.Exists( Path.Combine( destDir, "b.txt" ) ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CopyDirectory_Recursive( ) { + string srcDir = Path.Combine( _tempDir, "srcDir" ); + string subDir = Path.Combine( srcDir, "sub" ); + _ = Directory.CreateDirectory( subDir ); + await File.WriteAllTextAsync( Path.Combine( srcDir, "root.txt" ), "root", TestContext.CancellationToken ); + await File.WriteAllTextAsync( Path.Combine( subDir, "child.txt" ), "child", TestContext.CancellationToken ); + + string destDir = Path.Combine( _tempDir, "destDir" ); + + JsonElement parameters = Serialize( new CopyFileParameters { + Source = srcDir, + Destination = destDir, + Recursive = true + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( Path.Combine( destDir, "root.txt" ) ) ); + Assert.IsTrue( File.Exists( Path.Combine( destDir, "sub", "child.txt" ) ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CopyNoMatch_ReturnsFailure( ) { + string dest = Path.Combine( _tempDir, "out" ); + JsonElement parameters = Serialize( new CopyFileParameters { + Source = Path.Combine( _tempDir, "*.xyz" ), + Destination = dest + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CopySameSourceAndDest_ThrowsArgument( ) { + string path = Path.Combine( _tempDir, "file.txt" ); + await File.WriteAllTextAsync( path, "data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new CopyFileParameters { Source = path, Destination = path } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CopyDenied_ReturnsFailureWithUnauthorized( ) { + CopyFileHandler deniedHandler = new( TestFilePathResolver.DenyAll, NullLogger.Instance ); + string src = Path.Combine( _tempDir, "s.txt" ); + string dest = Path.Combine( _tempDir, "d.txt" ); + await File.WriteAllTextAsync( src, "data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new CopyFileParameters { Source = src, Destination = dest } ); + ActionOperatorResult result = await deniedHandler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateDirectoryHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateDirectoryHandlerTests.cs new file mode 100644 index 0000000..ca31b87 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateDirectoryHandlerTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +[TestClass] +public class CreateDirectoryHandlerTests { + + private string _tempDir = null!; + private CreateDirectoryHandler _handler = null!; + private Channel _channel = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new CreateDirectoryHandler( TestFilePathResolver.AllowAll, NullLogger.Instance ); + _channel = Channel.CreateUnbounded( ); + } + + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CreateDirectory_Succeeds( ) { + string path = Path.Combine( _tempDir, "newDir" ); + + JsonElement parameters = Serialize( new CreateDirectoryParameters { Path = path } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( Directory.Exists( path ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CreateDirectory_AlreadyExists_StillSucceeds( ) { + string path = Path.Combine( _tempDir, "existingDir" ); + _ = Directory.CreateDirectory( path ); + + JsonElement parameters = Serialize( new CreateDirectoryParameters { Path = path } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CreateDirectory_NestedPath_CreatesAll( ) { + string path = Path.Combine( _tempDir, "a", "b", "c" ); + + JsonElement parameters = Serialize( new CreateDirectoryParameters { Path = path } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( Directory.Exists( path ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CreateDirectory_FileExistsAtPath_ReturnsFailure( ) { + string path = Path.Combine( _tempDir, "conflicting" ); + await File.WriteAllTextAsync( path, "data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new CreateDirectoryParameters { Path = path } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateFileHandlerTests.cs new file mode 100644 index 0000000..6532515 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateFileHandlerTests.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +[TestClass] +public class CreateFileHandlerTests { + + private string _tempDir = null!; + private CreateFileHandler _handler = null!; + private Channel _channel = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new CreateFileHandler( TestFilePathResolver.AllowAll, NullLogger.Instance ); + _channel = Channel.CreateUnbounded( ); + } + + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CreateEmptyFile_Succeeds( ) { + string path = Path.Combine( _tempDir, "new.txt" ); + + JsonElement parameters = Serialize( new CreateFileParameters { Path = path } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( path ) ); + Assert.AreEqual( 0, new FileInfo( path ).Length ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CreateFileWithContent_Succeeds( ) { + string path = Path.Combine( _tempDir, "content.txt" ); + + JsonElement parameters = Serialize( new CreateFileParameters { Path = path, Content = "hello world" } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "hello world", await File.ReadAllTextAsync( path, TestContext.CancellationToken ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CreateFileCreatesParentDirectories( ) { + string path = Path.Combine( _tempDir, "sub", "deep", "file.txt" ); + + JsonElement parameters = Serialize( new CreateFileParameters { Path = path, CreateParentDirectories = true } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( path ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CreateFile_ExistsNoOverwrite_ReturnsFailure( ) { + string path = Path.Combine( _tempDir, "existing.txt" ); + await File.WriteAllTextAsync( path, "data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new CreateFileParameters { Path = path, Overwrite = false } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CreateFile_ExistsWithOverwrite_Succeeds( ) { + string path = Path.Combine( _tempDir, "existing.txt" ); + await File.WriteAllTextAsync( path, "old", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new CreateFileParameters { Path = path, Content = "new", Overwrite = true } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "new", await File.ReadAllTextAsync( path, TestContext.CancellationToken ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CreateFile_NoParentNoCreate_ReturnsFailure( ) { + string path = Path.Combine( _tempDir, "missing-parent", "file.txt" ); + + JsonElement parameters = Serialize( new CreateFileParameters { Path = path, CreateParentDirectories = false } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/DeleteFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/DeleteFileHandlerTests.cs new file mode 100644 index 0000000..b47ce70 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/DeleteFileHandlerTests.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +[TestClass] +public class DeleteFileHandlerTests { + + private string _tempDir = null!; + private DeleteFileHandler _handler = null!; + private Channel _channel = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new DeleteFileHandler( TestFilePathResolver.AllowAll, NullLogger.Instance ); + _channel = Channel.CreateUnbounded( ); + } + + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DeleteFile_Succeeds( ) { + string path = Path.Combine( _tempDir, "file.txt" ); + await File.WriteAllTextAsync( path, "data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new DeleteFileParameters { Path = path } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsFalse( File.Exists( path ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DeleteDirectory_Recursive( ) { + string dir = Path.Combine( _tempDir, "subdir" ); + _ = Directory.CreateDirectory( dir ); + await File.WriteAllTextAsync( Path.Combine( dir, "file.txt" ), "data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new DeleteFileParameters { Path = dir, Recursive = true } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsFalse( Directory.Exists( dir ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DeleteNonExistent_ReturnsFailure( ) { + string path = Path.Combine( _tempDir, "missing.txt" ); + + JsonElement parameters = Serialize( new DeleteFileParameters { Path = path } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DeleteReadOnly_ForceRemoves( ) { + string path = Path.Combine( _tempDir, "readonly.txt" ); + await File.WriteAllTextAsync( path, "data", TestContext.CancellationToken ); + File.SetAttributes( path, FileAttributes.ReadOnly ); + + JsonElement parameters = Serialize( new DeleteFileParameters { Path = path, Force = true } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsFalse( File.Exists( path ) ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/MoveFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/MoveFileHandlerTests.cs new file mode 100644 index 0000000..cab66cb --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/MoveFileHandlerTests.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +[TestClass] +public class MoveFileHandlerTests { + + private string _tempDir = null!; + private MoveFileHandler _handler = null!; + private Channel _channel = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new MoveFileHandler( TestFilePathResolver.AllowAll, NullLogger.Instance ); + _channel = Channel.CreateUnbounded( ); + } + + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task MoveSingleFile_Succeeds( ) { + string src = Path.Combine( _tempDir, "source.txt" ); + string dest = Path.Combine( _tempDir, "dest.txt" ); + await File.WriteAllTextAsync( src, "hello", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new MoveFileParameters { Source = src, Destination = dest } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsFalse( File.Exists( src ) ); + Assert.IsTrue( File.Exists( dest ) ); + Assert.AreEqual( "hello", await File.ReadAllTextAsync( dest, TestContext.CancellationToken ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task MoveDirectory_Succeeds( ) { + string srcDir = Path.Combine( _tempDir, "srcDir" ); + _ = Directory.CreateDirectory( srcDir ); + await File.WriteAllTextAsync( Path.Combine( srcDir, "file.txt" ), "data", TestContext.CancellationToken ); + + string destDir = Path.Combine( _tempDir, "destDir" ); + + JsonElement parameters = Serialize( new MoveFileParameters { Source = srcDir, Destination = destDir } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsFalse( Directory.Exists( srcDir ) ); + Assert.IsTrue( File.Exists( Path.Combine( destDir, "file.txt" ) ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task MoveNoMatch_ReturnsFailure( ) { + JsonElement parameters = Serialize( new MoveFileParameters { + Source = Path.Combine( _tempDir, "*.xyz" ), + Destination = Path.Combine( _tempDir, "out" ) + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task MoveSameSourceAndDest_ThrowsArgument( ) { + string path = Path.Combine( _tempDir, "file.txt" ); + await File.WriteAllTextAsync( path, "data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new MoveFileParameters { Source = path, Destination = path } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/RenameFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/RenameFileHandlerTests.cs new file mode 100644 index 0000000..f28e9fe --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/RenameFileHandlerTests.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +[TestClass] +public class RenameFileHandlerTests { + + private string _tempDir = null!; + private RenameFileHandler _handler = null!; + private Channel _channel = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new RenameFileHandler( TestFilePathResolver.AllowAll, NullLogger.Instance ); + _channel = Channel.CreateUnbounded( ); + } + + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task RenameFile_Succeeds( ) { + string path = Path.Combine( _tempDir, "old.txt" ); + await File.WriteAllTextAsync( path, "data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new RenameFileParameters { Path = path, NewName = "new.txt" } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsFalse( File.Exists( path ) ); + Assert.IsTrue( File.Exists( Path.Combine( _tempDir, "new.txt" ) ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task RenameDirectory_Succeeds( ) { + string dir = Path.Combine( _tempDir, "oldDir" ); + _ = Directory.CreateDirectory( dir ); + + JsonElement parameters = Serialize( new RenameFileParameters { Path = dir, NewName = "newDir" } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsFalse( Directory.Exists( dir ) ); + Assert.IsTrue( Directory.Exists( Path.Combine( _tempDir, "newDir" ) ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task RenameNonExistent_ReturnsFailure( ) { + string path = Path.Combine( _tempDir, "missing.txt" ); + + JsonElement parameters = Serialize( new RenameFileParameters { Path = path, NewName = "new.txt" } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task RenameFile_DestinationExists_OverwriteFalse_ReturnsFailure( ) { + string path = Path.Combine( _tempDir, "old.txt" ); + string existing = Path.Combine( _tempDir, "new.txt" ); + await File.WriteAllTextAsync( path, "data", TestContext.CancellationToken ); + await File.WriteAllTextAsync( existing, "existing", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new RenameFileParameters { Path = path, NewName = "new.txt", Overwrite = false } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task RenameFile_DestinationExists_OverwriteTrue_Succeeds( ) { + string path = Path.Combine( _tempDir, "old.txt" ); + string existing = Path.Combine( _tempDir, "new.txt" ); + await File.WriteAllTextAsync( path, "newdata", TestContext.CancellationToken ); + await File.WriteAllTextAsync( existing, "existing", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new RenameFileParameters { Path = path, NewName = "new.txt", Overwrite = true } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "newdata", await File.ReadAllTextAsync( Path.Combine( _tempDir, "new.txt" ), TestContext.CancellationToken ) ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/StartProcessHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/StartProcessHandlerTests.cs new file mode 100644 index 0000000..d74ffae --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/StartProcessHandlerTests.cs @@ -0,0 +1,170 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +[TestClass] +public class StartProcessHandlerTests { + + private StartProcessHandler _handler = null!; + private Channel _channel = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _handler = new StartProcessHandler( TestFilePathResolver.AllowAll, NullLogger.Instance ); + _channel = Channel.CreateUnbounded( ); + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task StartProcess_EchoCommand_Succeeds( ) { + string fileName; + string arguments; + if (OperatingSystem.IsWindows( )) { + fileName = "cmd.exe"; + arguments = "/c echo hello"; + } else { + fileName = "/bin/sh"; + arguments = "-c \"echo hello\""; + } + + JsonElement parameters = Serialize( new StartProcessParameters { + FileName = fileName, + Arguments = arguments, + WaitForExit = true + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task StartProcess_NonZeroExit_ReturnsFailure( ) { + string fileName; + string arguments; + if (OperatingSystem.IsWindows( )) { + fileName = "cmd.exe"; + arguments = "/c exit 1"; + } else { + fileName = "/bin/sh"; + arguments = "-c \"exit 1\""; + } + + JsonElement parameters = Serialize( new StartProcessParameters { + FileName = fileName, + Arguments = arguments, + WaitForExit = true + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task StartProcess_FireAndForget_ReturnsImmediately( ) { + string fileName; + string arguments; + if (OperatingSystem.IsWindows( )) { + fileName = "cmd.exe"; + arguments = "/c echo fire-and-forget"; + } else { + fileName = "/bin/sh"; + arguments = "-c \"echo fire-and-forget\""; + } + + JsonElement parameters = Serialize( new StartProcessParameters { + FileName = fileName, + Arguments = arguments, + WaitForExit = false + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task StartProcess_BareExecutable_SkipsPathValidation( ) { + // "dotnet" is a bare executable name — should not trigger path validation + JsonElement parameters = Serialize( new StartProcessParameters { + FileName = "dotnet", + Arguments = "--version", + WaitForExit = true + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task StartProcess_Timeout_KillsProcess( ) { + string fileName; + string arguments; + if (OperatingSystem.IsWindows( )) { + fileName = "cmd.exe"; + arguments = "/c ping -n 300 127.0.0.1"; + } else { + fileName = "/bin/sh"; + arguments = "-c \"sleep 300\""; + } + + JsonElement parameters = Serialize( new StartProcessParameters { + FileName = fileName, + Arguments = arguments, + WaitForExit = true, + TimeoutMs = 500 + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task StartProcess_CapturesOutput( ) { + string fileName; + string arguments; + if (OperatingSystem.IsWindows( )) { + fileName = "cmd.exe"; + arguments = "/c echo test-output"; + } else { + fileName = "/bin/sh"; + arguments = "-c \"echo test-output\""; + } + + JsonElement parameters = Serialize( new StartProcessParameters { + FileName = fileName, + Arguments = arguments, + WaitForExit = true + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + + // Drain the channel to check outputs + _channel.Writer.Complete( ); + List outputs = []; + await foreach (OperatorOutput output in _channel.Reader.ReadAllAsync( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + Assert.Contains( + o => o.Message.Contains( "test-output" ), + outputs, "Expected output containing 'test-output'." ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/StopProcessHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/StopProcessHandlerTests.cs new file mode 100644 index 0000000..c609b66 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/StopProcessHandlerTests.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +[TestClass] +public class StopProcessHandlerTests { + + private StopProcessHandler _handler = null!; + private Channel _channel = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _handler = new StopProcessHandler( NullLogger.Instance ); + _channel = Channel.CreateUnbounded( ); + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task StopProcess_NonexistentName_ReturnsFailure( ) { + JsonElement parameters = Serialize( new StopProcessParameters { + ProcessName = $"werkr-test-nonexistent-{Guid.NewGuid()}" + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task StopProcess_ByPid_StopsProcess( ) { + // Start a long-running process we can kill + ProcessStartInfo startInfo = OperatingSystem.IsWindows( ) + ? new ProcessStartInfo( "cmd.exe", "/c ping -n 300 127.0.0.1" ) { + CreateNoWindow = true, + UseShellExecute = false + } + : new ProcessStartInfo( "/bin/sh", "-c \"sleep 300\"" ) { + CreateNoWindow = true, + UseShellExecute = false + }; + using Process process = Process.Start( startInfo )!; + int pid = process.Id; + + try { + JsonElement parameters = Serialize( new StopProcessParameters { + ProcessName = process.ProcessName, + ProcessId = pid, + Force = true + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + // Give a moment for the process to actually exit + _ = process.WaitForExit( 5_000 ); + Assert.IsTrue( process.HasExited ); + } finally { + if (!process.HasExited) { + process.Kill( entireProcessTree: true ); + } + } + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task StopProcess_InvalidPid_ReturnsFailure( ) { + JsonElement parameters = Serialize( new StopProcessParameters { + ProcessName = "unused", + ProcessId = int.MaxValue, + Force = true + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/TestExistsHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/TestExistsHandlerTests.cs new file mode 100644 index 0000000..3b2cf52 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/TestExistsHandlerTests.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +[TestClass] +public class TestExistsHandlerTests { + + private string _tempDir = null!; + private TestExistsHandler _handler = null!; + private Channel _channel = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new TestExistsHandler( TestFilePathResolver.AllowAll, NullLogger.Instance ); + _channel = Channel.CreateUnbounded( ); + } + + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task FileExists_ReturnsSuccess( ) { + string path = Path.Combine( _tempDir, "file.txt" ); + await File.WriteAllTextAsync( path, "data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new TestExistsParameters { Path = path, Type = PathType.File } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task FileNotExists_ReturnsFailure( ) { + string path = Path.Combine( _tempDir, "missing.txt" ); + + JsonElement parameters = Serialize( new TestExistsParameters { Path = path, Type = PathType.File } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DirectoryExists_ReturnsSuccess( ) { + string path = Path.Combine( _tempDir, "subdir" ); + _ = Directory.CreateDirectory( path ); + + JsonElement parameters = Serialize( new TestExistsParameters { Path = path, Type = PathType.Directory } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DirectoryNotExists_ReturnsFailure( ) { + string path = Path.Combine( _tempDir, "missing-dir" ); + + JsonElement parameters = Serialize( new TestExistsParameters { Path = path, Type = PathType.Directory } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task AnyType_FileExists_ReturnsSuccess( ) { + string path = Path.Combine( _tempDir, "file.txt" ); + await File.WriteAllTextAsync( path, "data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new TestExistsParameters { Path = path, Type = PathType.Any } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task AnyType_DirectoryExists_ReturnsSuccess( ) { + string path = Path.Combine( _tempDir, "dir" ); + _ = Directory.CreateDirectory( path ); + + JsonElement parameters = Serialize( new TestExistsParameters { Path = path, Type = PathType.Any } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task FileType_OnDirectory_ReturnsFailure( ) { + string path = Path.Combine( _tempDir, "dir" ); + _ = Directory.CreateDirectory( path ); + + JsonElement parameters = Serialize( new TestExistsParameters { Path = path, Type = PathType.File } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/WriteContentHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/WriteContentHandlerTests.cs new file mode 100644 index 0000000..ff95e28 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/WriteContentHandlerTests.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +[TestClass] +public class WriteContentHandlerTests { + + private string _tempDir = null!; + private WriteContentHandler _handler = null!; + private Channel _channel = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new WriteContentHandler( TestFilePathResolver.AllowAll, NullLogger.Instance ); + _channel = Channel.CreateUnbounded( ); + } + + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WriteContent_NewFile_Succeeds( ) { + string path = Path.Combine( _tempDir, "file.txt" ); + + JsonElement parameters = Serialize( new WriteContentParameters { Path = path, Content = "hello" } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "hello", await File.ReadAllTextAsync( path, TestContext.CancellationToken ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WriteContent_Overwrite_ReplacesContent( ) { + string path = Path.Combine( _tempDir, "file.txt" ); + await File.WriteAllTextAsync( path, "old", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new WriteContentParameters { Path = path, Content = "new" } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "new", await File.ReadAllTextAsync( path, TestContext.CancellationToken ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WriteContent_Append_AppendsContent( ) { + string path = Path.Combine( _tempDir, "file.txt" ); + await File.WriteAllTextAsync( path, "hello", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new WriteContentParameters { Path = path, Content = " world", Append = true } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "hello world", await File.ReadAllTextAsync( path, TestContext.CancellationToken ) ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WriteContent_CustomEncoding( ) { + string path = Path.Combine( _tempDir, "ascii.txt" ); + + JsonElement parameters = Serialize( new WriteContentParameters { Path = path, Content = "test", Encoding = "ascii" } ); + ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( path ) ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/PSHostTests.cs b/src/Test/Werkr.Tests.Agent/Operators/PSHostTests.cs new file mode 100644 index 0000000..7f6d6b5 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/PSHostTests.cs @@ -0,0 +1,264 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +using Werkr.Agent.Operators; +using Werkr.Common.Configuration; +using Werkr.Core.Communication; +using Werkr.Core.Operators; + +namespace Werkr.Tests.Agent.Operators; + +/// +/// Tests for the custom PSHost integration in . +/// Validates that Format-Table, Format-List, Write-Host, +/// Write-Progress, and raw pipeline output all route correctly through +/// into the channel. +/// +[TestClass] +public class PSHostTests { + public TestContext TestContext { get; set; } = null!; + + private static PwshOperator CreateOperator( int bufferWidth = 150 ) { + AgentSettings settings = new( ) { + PowerShell = new PowerShellSettings { BufferWidth = bufferWidth } + }; + return new PwshOperator( + Options.Create( settings ), + NullLogger.Instance ); + } + + private static async Task> CollectOutputAsync( + OperatorExecution execution, CancellationToken ct ) { + List outputs = []; + await foreach (OperatorOutput output in execution.Output.WithCancellation( ct )) { + outputs.Add( output ); + } + return outputs; + } + + [TestMethod] + public async Task FormatTable_ProducesColumnarOutput( ) { + PwshOperator op = CreateOperator( ); + OperatorExecution execution = op.RunCommand( + "Get-ChildItem -Path / -Force -ErrorAction SilentlyContinue | Select-Object -First 3 | Format-Table -AutoSize", + TestContext.CancellationToken ); + + List outputs = await CollectOutputAsync( execution, TestContext.CancellationToken ); + + // Should contain formatted table content, NOT raw FormatEntryData type names + string allOutput = string.Join( "\n", outputs + .Where( o => o.LogLevel == "Information" ) + .Select( o => o.Message ) ); + + Assert.IsFalse( allOutput.Contains( "FormatEntryData", StringComparison.OrdinalIgnoreCase ), + $"Output should not contain raw FormatEntryData type names. Got:\n{allOutput}" ); + Assert.IsFalse( allOutput.Contains( "FormatStartData", StringComparison.OrdinalIgnoreCase ), + $"Output should not contain raw FormatStartData type names. Got:\n{allOutput}" ); + Assert.IsGreaterThan( 0, allOutput.Length, "Expected some formatted output from Get-ChildItem." ); + } + + [TestMethod] + public async Task FormatTable_GetProcess_ProducesFormattedTable( ) { + PwshOperator op = CreateOperator( ); + OperatorExecution execution = op.RunCommand( + "Get-Process | Select-Object -First 5 | Format-Table -Property Id, ProcessName -AutoSize", + TestContext.CancellationToken ); + + List outputs = await CollectOutputAsync( execution, TestContext.CancellationToken ); + + string allOutput = string.Join( "\n", outputs + .Where( o => o.LogLevel == "Information" ) + .Select( o => o.Message ) ); + + Assert.IsFalse( allOutput.Contains( "FormatEntryData", StringComparison.OrdinalIgnoreCase ), + "Format-Table should produce rendered table, not FormatEntryData." ); + Assert.IsGreaterThan( 0, allOutput.Length, "Expected formatted process table output." ); + } + + [TestMethod] + public async Task FormatList_ProducesPropertyList( ) { + PwshOperator op = CreateOperator( ); + OperatorExecution execution = op.RunCommand( + "Get-Process | Select-Object -First 1 | Format-List -Property Id, ProcessName", + TestContext.CancellationToken ); + + List outputs = await CollectOutputAsync( execution, TestContext.CancellationToken ); + + string allOutput = string.Join( "\n", outputs + .Where( o => o.LogLevel == "Information" ) + .Select( o => o.Message ) ); + + Assert.IsFalse( allOutput.Contains( "FormatEntryData", StringComparison.OrdinalIgnoreCase ), + "Format-List should produce property list, not FormatEntryData." ); + Assert.IsGreaterThan( 0, allOutput.Length, "Expected formatted property list output." ); + } + + [TestMethod] + public async Task WriteHost_CapturesText( ) { + PwshOperator op = CreateOperator( ); + OperatorExecution execution = op.RunCommand( + "Write-Host 'Hello from PSHost'", + TestContext.CancellationToken ); + + List outputs = await CollectOutputAsync( execution, TestContext.CancellationToken ); + + Assert.Contains( + o => o.LogLevel == "Information" && o.Message.Contains( "Hello from PSHost" ), + outputs, "Expected Information-level output containing 'Hello from PSHost'." ); + } + + [TestMethod] + public async Task WriteProgress_CapturesProgressOutput( ) { + PwshOperator op = CreateOperator( ); + OperatorExecution execution = op.RunCommand( + "Write-Progress -Activity 'TestActivity' -Status 'Running' -PercentComplete 50", + TestContext.CancellationToken ); + + List outputs = await CollectOutputAsync( execution, TestContext.CancellationToken ); + + Assert.Contains( + o => o.LogLevel == "Progress" && o.Message.Contains( "TestActivity" ), + outputs, "Expected Progress-level output containing 'TestActivity'." ); + } + + [TestMethod] + public async Task RawPipeline_RendersIntegers( ) { + PwshOperator op = CreateOperator( ); + OperatorExecution execution = op.RunCommand( + "1..5 | ForEach-Object { $_ }", + TestContext.CancellationToken ); + + List outputs = await CollectOutputAsync( execution, TestContext.CancellationToken ); + + string allOutput = string.Join( "\n", outputs + .Where( o => o.LogLevel == "Information" ) + .Select( o => o.Message ) ); + + // All integers 1-5 should appear somewhere in the output (rendered via Out-Default) + for (int i = 1; i <= 5; i++) { + Assert.Contains( i.ToString( ), allOutput, + $"Expected integer {i} in output. Got:\n{allOutput}" ); + } + } + + [TestMethod] + public async Task WriteOutput_CapturedViaSingleFlow( ) { + PwshOperator op = CreateOperator( ); + OperatorExecution execution = op.RunCommand( + "Write-Output 'single-flow-test'", + TestContext.CancellationToken ); + + List outputs = await CollectOutputAsync( execution, TestContext.CancellationToken ); + + Assert.Contains( + o => o.LogLevel == "Information" && o.Message.Contains( "single-flow-test" ), + outputs, "Write-Output should route through PSHost UI via Out-Default." ); + } + + [TestMethod] + public async Task BufferWidth_AffectsFormatting( ) { + // Use a very narrow buffer to force wrapping + PwshOperator narrowOp = CreateOperator( bufferWidth: 40 ); + OperatorExecution narrowExec = narrowOp.RunCommand( + "Get-Process | Select-Object -First 3 | Format-Table -Property Id, ProcessName, CPU", + TestContext.CancellationToken ); + + List narrowOutputs = await CollectOutputAsync( narrowExec, TestContext.CancellationToken ); + + // Use a wide buffer + PwshOperator wideOp = CreateOperator( bufferWidth: 200 ); + OperatorExecution wideExec = wideOp.RunCommand( + "Get-Process | Select-Object -First 3 | Format-Table -Property Id, ProcessName, CPU", + TestContext.CancellationToken ); + + List wideOutputs = await CollectOutputAsync( wideExec, TestContext.CancellationToken ); + + string narrowText = string.Join( "\n", narrowOutputs + .Where( o => o.LogLevel == "Information" ) + .Select( o => o.Message ) ); + string wideText = string.Join( "\n", wideOutputs + .Where( o => o.LogLevel == "Information" ) + .Select( o => o.Message ) ); + + // Both should produce output — the actual text may differ due to buffer width + Assert.IsGreaterThan( 0, narrowText.Length, "Narrow buffer should still produce output." ); + Assert.IsGreaterThan( 0, wideText.Length, "Wide buffer should produce output." ); + + // They should not be identical (different wrapping behavior) + // Unless the table is small enough to fit in both widths (unlikely with 3 processes + 3 columns) + // At minimum, both should not contain FormatEntryData + Assert.IsFalse( narrowText.Contains( "FormatEntryData", StringComparison.OrdinalIgnoreCase ), + "Narrow buffer should not produce raw FormatEntryData." ); + Assert.IsFalse( wideText.Contains( "FormatEntryData", StringComparison.OrdinalIgnoreCase ), + "Wide buffer should not produce raw FormatEntryData." ); + } + + [TestMethod] + public async Task WriteError_CapturedViaHostUI( ) { + PwshOperator op = CreateOperator( ); + OperatorExecution execution = op.RunCommand( + "Write-Error 'pshost-error-test'", + TestContext.CancellationToken ); + + List outputs = await CollectOutputAsync( execution, TestContext.CancellationToken ); + IOperatorResult result = await execution.Result; + + Assert.Contains( + o => o.LogLevel == "Error" && o.Message.Contains( "pshost-error-test" ), + outputs, "Write-Error should be captured via PSHost UI ErrorLine." ); + Assert.IsTrue( ((PwshOperatorResult)result).HadErrors, "HadErrors should be true after Write-Error." ); + } + + [TestMethod] + public async Task WriteWarning_CapturedViaHostUI( ) { + PwshOperator op = CreateOperator( ); + OperatorExecution execution = op.RunCommand( + "Write-Warning 'pshost-warning-test'", + TestContext.CancellationToken ); + + List outputs = await CollectOutputAsync( execution, TestContext.CancellationToken ); + + Assert.Contains( + o => o.LogLevel == "Warning" && o.Message.Contains( "pshost-warning-test" ), + outputs, "Write-Warning should be captured via PSHost UI WarningLine." ); + } + + [TestMethod] + public async Task HadErrors_StillWorksWithCustomRunspace( ) { + PwshOperator op = CreateOperator( ); + + // A command that succeeds — HadErrors should be false + OperatorExecution successExec = op.RunCommand( + "Write-Output 'success'", TestContext.CancellationToken ); + _ = await CollectOutputAsync( successExec, TestContext.CancellationToken ); + IOperatorResult successResult = await successExec.Result; + Assert.IsFalse( ((PwshOperatorResult)successResult).HadErrors, + "HadErrors should be false for a successful command." ); + + // A command that errors — HadErrors should be true + OperatorExecution errorExec = op.RunCommand( + "Write-Error 'fail'", TestContext.CancellationToken ); + _ = await CollectOutputAsync( errorExec, TestContext.CancellationToken ); + IOperatorResult errorResult = await errorExec.Result; + Assert.IsTrue( ((PwshOperatorResult)errorResult).HadErrors, + "HadErrors should be true after Write-Error." ); + } + + [TestMethod] + public async Task NoDuplicateOutput_WriteHost( ) { + PwshOperator op = CreateOperator( ); + OperatorExecution execution = op.RunCommand( + "Write-Host 'unique-message-42'", + TestContext.CancellationToken ); + + List outputs = await CollectOutputAsync( execution, TestContext.CancellationToken ); + + // Count occurrences — should be exactly 1, not duplicated + int count = outputs.Count( o => + o.LogLevel == "Information" && o.Message.Contains( "unique-message-42" ) ); + + Assert.AreEqual( 1, count, + $"Write-Host should produce exactly 1 output, not {count}. Found:\n" + + string.Join( "\n", outputs.Select( o => $"[{o.LogLevel}] {o.Message}" ) ) ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/PwshOperatorTests.cs b/src/Test/Werkr.Tests.Agent/Operators/PwshOperatorTests.cs new file mode 100644 index 0000000..86aa03f --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/PwshOperatorTests.cs @@ -0,0 +1,117 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +using Werkr.Agent.Operators; +using Werkr.Common.Configuration; +using Werkr.Core.Communication; +using Werkr.Core.Operators; + +namespace Werkr.Tests.Agent.Operators; + +[TestClass] +public class PwshOperatorTests { + private PwshOperator _operator = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _operator = new PwshOperator( + Options.Create( new AgentSettings( ) ), + NullLogger.Instance ); + } + + [TestMethod] + public void IsAvailable_ReturnsTrue( ) { + Assert.IsTrue( _operator.IsAvailable ); + } + + [TestMethod] + public async Task RunCommand_ProducesOutput( ) { + List outputs = []; + + OperatorExecution execution = _operator.RunCommand( "Write-Output 'hello'", TestContext.CancellationToken ); + await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + Assert.IsNotEmpty( outputs, "Expected at least one output line." ); + Assert.Contains( + o => o.LogLevel == "Information" && o.Message.Contains( "hello" ), + outputs, "Expected Information-level output containing 'hello'." ); + } + + [TestMethod] + public async Task RunCommand_ErrorStream( ) { + List outputs = []; + + OperatorExecution execution = _operator.RunCommand( "Write-Error 'fail'", TestContext.CancellationToken ); + await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + Assert.Contains( + o => o.LogLevel == "Error" && o.Message.Contains( "fail" ), + outputs, "Expected Error-level output containing 'fail'." ); + } + + [TestMethod] + public async Task RunCommand_MultipleStreams( ) { + List outputs = []; + + OperatorExecution execution = _operator.RunCommand( + "Write-Output 'out'; Write-Warning 'warn'; Write-Error 'err'", TestContext.CancellationToken ); + await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + Assert.Contains( + o => o.LogLevel == "Information" && o.Message.Contains( "out" ), + outputs, "Expected Information-level output." ); + Assert.Contains( + o => o.LogLevel == "Warning" && o.Message.Contains( "warn" ), + outputs, "Expected Warning-level output." ); + Assert.Contains( + o => o.LogLevel == "Error" && o.Message.Contains( "err" ), + outputs, "Expected Error-level output." ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task RunCommand_Cancellation( ) { + using CancellationTokenSource cts = new( TimeSpan.FromSeconds( 1 ) ); + List outputs = []; + + bool threwCancellation = false; + + try { + OperatorExecution execution = _operator.RunCommand( "Start-Sleep 300", cts.Token ); + await foreach (OperatorOutput output in execution.Output.WithCancellation( cts.Token )) { + outputs.Add( output ); + } + } catch (OperationCanceledException) { + threwCancellation = true; + } + + // The operator writes a "Cancelled" warning, but the channel reader is enumerated with the same + // cancellation token and may throw before the warning is observed. Either outcome is acceptable. + bool observedCancellationWarning = outputs.Any( + o => o.LogLevel == "Warning" && o.Message.Contains( "Cancelled" ) ); + + Assert.IsTrue( threwCancellation || observedCancellationWarning, "Expected cancellation to stop enumeration." ); + } + + [TestMethod] + public async Task RunScript_FileNotFound( ) { + List outputs = []; + + OperatorExecution execution = _operator.RunScript( "C:\\nonexistent\\fake.ps1", TestContext.CancellationToken ); + await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + Assert.Contains( + o => o.LogLevel == "Error" && o.Message.Contains( "not found" ), + outputs, "Expected error about file not found." ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/SystemShellOperatorTests.cs b/src/Test/Werkr.Tests.Agent/Operators/SystemShellOperatorTests.cs new file mode 100644 index 0000000..4c9401f --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/SystemShellOperatorTests.cs @@ -0,0 +1,137 @@ +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Agent.Operators; +using Werkr.Core.Communication; +using Werkr.Core.Operators; + +namespace Werkr.Tests.Agent.Operators; + +[TestClass] +public class SystemShellOperatorTests { + private SystemShellOperator _operator = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _operator = new SystemShellOperator( NullLogger.Instance ); + } + + [TestMethod] + public void IsAvailable_ReturnsTrueOnSupportedPlatform( ) { + // Windows, Linux, or macOS should report available + bool expected = OperatingSystem.IsWindows( ) || OperatingSystem.IsLinux( ) || OperatingSystem.IsMacOS( ); + Assert.AreEqual( expected, _operator.IsAvailable ); + } + + [TestMethod] + public async Task RunCommand_StdOut( ) { + List outputs = []; + + OperatorExecution execution = _operator.RunCommand( "echo hello", TestContext.CancellationToken ); + await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + Assert.IsNotEmpty( outputs, "Expected at least one output line." ); + Assert.Contains( + o => o.LogLevel == "Information" && o.Message.Contains( "hello" ), + outputs, "Expected Information-level output containing 'hello'." ); + } + + [TestMethod] + public async Task RunCommand_NonZeroExitCode( ) { + string command = OperatingSystem.IsWindows( ) + ? "cmd /c exit 1" + : "exit 1"; + + List outputs = []; + + OperatorExecution execution = _operator.RunCommand( command, TestContext.CancellationToken ); + await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + Assert.Contains( + o => o.LogLevel == "Error" && o.Message.Contains( "Exited with code" ), + outputs, "Expected error output with exit code." ); + } + + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task RunCommand_Cancellation( ) { + string command = OperatingSystem.IsWindows( ) + ? "ping -n 300 127.0.0.1" + : "sleep 300"; + + using CancellationTokenSource cts = new( TimeSpan.FromSeconds( 1 ) ); + List outputs = []; + + bool threwCancellation = false; + + try { + OperatorExecution execution = _operator.RunCommand( command, cts.Token ); + await foreach (OperatorOutput output in execution.Output.WithCancellation( cts.Token )) { + outputs.Add( output ); + } + } catch (OperationCanceledException) { + threwCancellation = true; + } + + bool observedCancellationWarning = outputs.Any( + o => o.LogLevel == "Warning" && o.Message.Contains( "Cancelled" ) ); + + Assert.IsTrue( threwCancellation || observedCancellationWarning, "Expected cancellation to stop enumeration." ); + } + + [TestMethod] + public async Task RunCommand_CrossPlatform_UsesCorrectShell( ) { + // On Windows, "ver" produces Windows version output + // On Linux/macOS, "uname" produces system name + string command = OperatingSystem.IsWindows( ) ? "ver" : "uname"; + + List outputs = []; + + OperatorExecution execution = _operator.RunCommand( command, TestContext.CancellationToken ); + await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + Assert.Contains( + o => o.LogLevel == "Information" && o.Message.Length > 0, + outputs, "Expected at least one Information-level output line." ); + } + + [TestMethod] + public async Task RunScript_FileNotFound( ) { + List outputs = []; + + OperatorExecution execution = _operator.RunScript( "C:\\nonexistent\\fake.bat", TestContext.CancellationToken ); + await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + Assert.Contains( + o => o.LogLevel == "Error" && o.Message.Contains( "not found" ), + outputs, "Expected error about file not found." ); + } + + [TestMethod] + public async Task RunCommand_StdErr( ) { + // Force stderr output on Windows + string command = OperatingSystem.IsWindows( ) + ? "echo error_msg 1>&2" + : "echo error_msg >&2"; + + List outputs = []; + + OperatorExecution execution = _operator.RunCommand( command, TestContext.CancellationToken ); + await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { + outputs.Add( output ); + } + + Assert.Contains( + o => o.LogLevel == "Error" && o.Message.Contains( "error_msg" ), + outputs, "Expected Error-level output from stderr." ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Scheduling/ScheduleEvaluatorServiceTests.cs b/src/Test/Werkr.Tests.Agent/Scheduling/ScheduleEvaluatorServiceTests.cs new file mode 100644 index 0000000..f8b3896 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Scheduling/ScheduleEvaluatorServiceTests.cs @@ -0,0 +1,338 @@ +using Werkr.Agent.Scheduling; +using Werkr.Common.Protos; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Models; + +namespace Werkr.Tests.Agent.Scheduling; + +[TestClass] +public class ScheduleEvaluatorServiceTests { + + #region MapProtoToSchedule + + [TestMethod] + public void MapProtoToSchedule_BasicSchedule_MapsCorrectly( ) { + // Arrange + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + StopTaskAfterMinutes = 60, + }; + + // Act + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + // Assert + Assert.IsNotNull( schedule ); + Assert.IsNotNull( schedule.StartDateTime ); + Assert.AreEqual( new DateOnly( 2025, 6, 15 ), schedule.StartDateTime.Date ); + Assert.AreEqual( new TimeOnly( 8, 30 ), schedule.StartDateTime.Time ); + Assert.AreEqual( "UTC", schedule.StartDateTime.TimeZone.Id ); + Assert.AreEqual( 60, schedule.DbSchedule.StopTaskAfterMinutes ); + Assert.IsNull( schedule.Expiration ); + Assert.IsNull( schedule.DailyRecurrence ); + Assert.IsNull( schedule.WeeklyRecurrence ); + Assert.IsNull( schedule.MonthlyRecurrence ); + Assert.IsNull( schedule.RepeatOptions ); + } + + [TestMethod] + public void MapProtoToSchedule_WithExpiration_MapsCorrectly( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + ExpirationDate = "2025-12-31", + ExpirationTime = "23:59", + ExpirationTimeZoneId = "UTC", + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.IsNotNull( schedule.Expiration ); + Assert.AreEqual( new DateOnly( 2025, 12, 31 ), schedule.Expiration.Date ); + Assert.AreEqual( new TimeOnly( 23, 59 ), schedule.Expiration.Time ); + } + + [TestMethod] + public void MapProtoToSchedule_WithDailyRecurrence_MapsCorrectly( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + Daily = new DailyRecurrenceDef { DayInterval = 3 }, + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.IsNotNull( schedule.DailyRecurrence ); + Assert.AreEqual( 3, schedule.DailyRecurrence.DayInterval ); + Assert.IsNull( schedule.WeeklyRecurrence ); + Assert.IsNull( schedule.MonthlyRecurrence ); + } + + [TestMethod] + public void MapProtoToSchedule_WithWeeklyRecurrence_MapsCorrectly( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + Weekly = new WeeklyRecurrenceDef { + WeekInterval = 2, + RecurrenceDays = (int) ( DaysOfWeek.Monday | DaysOfWeek.Friday ), + }, + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.IsNotNull( schedule.WeeklyRecurrence ); + Assert.AreEqual( 2, schedule.WeeklyRecurrence.WeekInterval ); + Assert.AreEqual( DaysOfWeek.Monday | DaysOfWeek.Friday, schedule.WeeklyRecurrence.DaysOfWeek ); + } + + [TestMethod] + public void MapProtoToSchedule_WithMonthlyRecurrence_MapsCorrectly( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + Monthly = new MonthlyRecurrenceDef { + MonthsOfYear = (int) ( MonthsOfYear.January | MonthsOfYear.July ), + WeekNumber = (int) WeekNumberWithinMonth.First, + DaysOfWeek = (int) DaysOfWeek.Monday, + }, + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.IsNotNull( schedule.MonthlyRecurrence ); + Assert.AreEqual( MonthsOfYear.January | MonthsOfYear.July, schedule.MonthlyRecurrence.MonthsOfYear ); + Assert.AreEqual( WeekNumberWithinMonth.First, schedule.MonthlyRecurrence.WeekNumber ); + Assert.AreEqual( DaysOfWeek.Monday, schedule.MonthlyRecurrence.DaysOfWeek ); + } + + [TestMethod] + public void MapProtoToSchedule_WithRepeatOptions_MapsCorrectly( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + Repeat = new RepeatOptionsDef { + IntervalMinutes = 15, + DurationMinutes = 120, + }, + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.IsNotNull( schedule.RepeatOptions ); + Assert.AreEqual( 15, schedule.RepeatOptions.RepeatIntervalMinutes ); + Assert.AreEqual( 120, schedule.RepeatOptions.RepeatDurationMinutes ); + } + + [TestMethod] + public void MapProtoToSchedule_EmptyDailyRecurrence_DoesNotMap( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + Daily = new DailyRecurrenceDef { DayInterval = 0 }, + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.IsNull( schedule.DailyRecurrence ); + } + + [TestMethod] + public void MapProtoToSchedule_EmptyRepeatOptions_DoesNotMap( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + Repeat = new RepeatOptionsDef { IntervalMinutes = 0, DurationMinutes = 0 }, + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.IsNull( schedule.RepeatOptions ); + } + + [TestMethod] + public void MapProtoToSchedule_InvalidScheduleId_UsesEmptyGuid( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = "not-a-guid", + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.AreEqual( Guid.Empty, schedule.DbSchedule.Id ); + } + + [TestMethod] + public void MapProtoToSchedule_EmptyTime_DefaultsToMidnight( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "", + TimeZoneId = "UTC", + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.AreEqual( TimeOnly.MinValue, schedule.StartDateTime!.Time ); + } + + #endregion MapProtoToSchedule + + #region MapProtoToSchedule — Holiday Fields + + [TestMethod] + public void MapProtoToSchedule_WithHolidayCalendar_Blocklist_SetsProperties( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + HasHolidayCalendar = true, + HolidayCalendarMode = "Blocklist", + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.IsNotNull( schedule.HolidayCalendar ); + Assert.AreEqual( HolidayCalendarMode.Blocklist, schedule.HolidayCalendarMode ); + } + + [TestMethod] + public void MapProtoToSchedule_WithHolidayCalendar_Allowlist_SetsProperties( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + HasHolidayCalendar = true, + HolidayCalendarMode = "Allowlist", + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.IsNotNull( schedule.HolidayCalendar ); + Assert.AreEqual( HolidayCalendarMode.Allowlist, schedule.HolidayCalendarMode ); + } + + [TestMethod] + public void MapProtoToSchedule_WithoutHolidayCalendar_LeavesNull( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + HasHolidayCalendar = false, + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.IsNull( schedule.HolidayCalendar ); + Assert.IsNull( schedule.HolidayCalendarMode ); + } + + [TestMethod] + public void MapProtoToSchedule_InvalidMode_SetsCalendarButModeNull( ) { + ScheduleDefinition proto = new( ) { + ScheduleId = Guid.NewGuid( ).ToString( ), + StartDate = "2025-06-15", + StartTime = "08:30", + TimeZoneId = "UTC", + HasHolidayCalendar = true, + HolidayCalendarMode = "InvalidValue", + }; + + Schedule schedule = ScheduleEvaluatorService.MapProtoToSchedule( proto ); + + Assert.IsNotNull( schedule.HolidayCalendar ); + Assert.IsNull( schedule.HolidayCalendarMode ); + } + + #endregion MapProtoToSchedule — Holiday Fields + + #region FireQueueEntry Comparisons + + [TestMethod] + public void FireQueueEntry_OrdersByFireTime( ) { + DateTime earlier = DateTime.UtcNow; + DateTime later = earlier.AddMinutes( 10 ); + + ScheduleEvaluatorService.FireQueueEntry entry1 = new( earlier, CreateTaskDef( 1 ), null ); + ScheduleEvaluatorService.FireQueueEntry entry2 = new( later, CreateTaskDef( 2 ), null ); + + Assert.IsLessThan( 0, entry1.CompareTo( entry2 ) ); + Assert.IsGreaterThan( 0, entry2.CompareTo( entry1 ) ); + } + + [TestMethod] + public void FireQueueEntry_SameTime_BreaksTiesById( ) { + DateTime time = DateTime.UtcNow; + + ScheduleEvaluatorService.FireQueueEntry entry1 = new( time, CreateTaskDef( 1 ), null ); + ScheduleEvaluatorService.FireQueueEntry entry2 = new( time, CreateTaskDef( 5 ), null ); + + Assert.IsLessThan( 0, entry1.CompareTo( entry2 ) ); + } + + [TestMethod] + public void FireQueueEntry_SameTimeAndId_ReturnsZero( ) { + DateTime time = DateTime.UtcNow; + + ScheduleEvaluatorService.FireQueueEntry entry1 = new( time, CreateTaskDef( 3 ), null ); + ScheduleEvaluatorService.FireQueueEntry entry2 = new( time, CreateTaskDef( 3 ), null ); + + Assert.AreEqual( 0, entry1.CompareTo( entry2 ) ); + } + + [TestMethod] + public void FireQueueEntry_NullOther_ReturnsPositive( ) { + DateTime time = DateTime.UtcNow; + ScheduleEvaluatorService.FireQueueEntry entry = new( time, CreateTaskDef( 1 ), null ); + + Assert.IsGreaterThan( 0, entry.CompareTo( null ) ); + } + + #endregion FireQueueEntry Comparisons + + #region AgentJobOutputWriter + + [TestMethod] + public void GetRelativeOutputPath_ReturnsCorrectFormat( ) { + Guid jobId = Guid.Parse( "12345678-1234-1234-1234-123456789abc" ); + string path = AgentJobOutputWriter.GetRelativeOutputPath( jobId ); + Assert.AreEqual( "12345678-1234-1234-1234-123456789abc.log", path ); + } + + #endregion AgentJobOutputWriter + + #region Helpers + + private static ScheduledTaskDefinition CreateTaskDef( long taskId ) => new( ) { + TaskId = taskId, + Name = $"TestTask{taskId}", + ActionType = 0, + Content = "echo test", + TimeoutMinutes = 5, + SyncIntervalMinutes = 30, + }; + + #endregion Helpers +} diff --git a/src/Test/Werkr.Tests.Agent/Security/FilePathResolverTests.cs b/src/Test/Werkr.Tests.Agent/Security/FilePathResolverTests.cs new file mode 100644 index 0000000..2170ec1 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Security/FilePathResolverTests.cs @@ -0,0 +1,114 @@ +using Werkr.Agent.Security; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Security; + +[TestClass] +public class FilePathResolverTests { + + private string _tempDir = null!; + + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + } + + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + [TestMethod] + public void ResolveSinglePath_ValidPath_ReturnsFullPath( ) { + FilePathResolver resolver = new( new AllowAllPathValidator() ); + string testFile = Path.Combine( _tempDir, "test.txt" ); + + string result = resolver.ResolveSinglePath( testFile ); + + Assert.AreEqual( Path.GetFullPath( testFile ), result ); + } + + [TestMethod] + public void ResolveSinglePath_DenyAll_ThrowsUnauthorized( ) { + FilePathResolver resolver = new( new DenyAllPathValidator() ); + string testFile = Path.Combine( _tempDir, "test.txt" ); + + _ = Assert.ThrowsExactly( + ( ) => resolver.ResolveSinglePath( testFile ) ); + } + + [TestMethod] + public void ResolveSinglePath_RestrictedPrefix_OutsidePath_Throws( ) { + FilePathResolver resolver = new( new AllowPrefixValidator( _tempDir ) ); + string outsidePath = Path.Combine( Path.GetTempPath(), "other-dir", "file.txt" ); + + _ = Assert.ThrowsExactly( + ( ) => resolver.ResolveSinglePath( outsidePath ) ); + } + + [TestMethod] + public void ResolveFiles_WildcardMatches( ) { + FilePathResolver resolver = new( new AllowAllPathValidator() ); + File.WriteAllText( Path.Combine( _tempDir, "a.txt" ), "a" ); + File.WriteAllText( Path.Combine( _tempDir, "b.txt" ), "b" ); + File.WriteAllText( Path.Combine( _tempDir, "c.log" ), "c" ); + + string wildcard = Path.Combine( _tempDir, "*.txt" ); + string[] results = resolver.ResolveFiles( wildcard ); + + Assert.HasCount( 2, results ); + } + + [TestMethod] + public void ResolveFiles_NoMatches_ReturnsEmpty( ) { + FilePathResolver resolver = new( new AllowAllPathValidator() ); + + string wildcard = Path.Combine( _tempDir, "*.xyz" ); + string[] results = resolver.ResolveFiles( wildcard ); + + Assert.IsEmpty( results ); + } + + [TestMethod] + public void ResolveFiles_DirectoryDoesNotExist_ReturnsEmpty( ) { + FilePathResolver resolver = new( new AllowAllPathValidator() ); + + string wildcard = Path.Combine( _tempDir, "nonexistent", "*.txt" ); + string[] results = resolver.ResolveFiles( wildcard ); + + Assert.IsEmpty( results ); + } + + [TestMethod] + public void ResolveFiles_DenyAll_ThrowsForEachFile( ) { + FilePathResolver resolver = new( new DenyAllPathValidator() ); + File.WriteAllText( Path.Combine( _tempDir, "a.txt" ), "a" ); + + string wildcard = Path.Combine( _tempDir, "*.txt" ); + + _ = Assert.ThrowsExactly( + ( ) => resolver.ResolveFiles( wildcard ) ); + } + + [TestMethod] + public void ValidateSourceDestination_SamePath_ThrowsArgument( ) { + FilePathResolver resolver = new( new AllowAllPathValidator() ); + string path = Path.Combine( _tempDir, "file.txt" ); + + _ = Assert.ThrowsExactly( + ( ) => resolver.ValidateSourceDestination( path, path ) ); + } + + [TestMethod] + public void ValidateSourceDestination_DifferentPaths_NoThrow( ) { + FilePathResolver resolver = new( new AllowAllPathValidator() ); + string source = Path.Combine( _tempDir, "a.txt" ); + string dest = Path.Combine( _tempDir, "b.txt" ); + + // Should not throw + resolver.ValidateSourceDestination( source, dest ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Security/PathAllowlistValidatorTests.cs b/src/Test/Werkr.Tests.Agent/Security/PathAllowlistValidatorTests.cs new file mode 100644 index 0000000..2f989a5 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Security/PathAllowlistValidatorTests.cs @@ -0,0 +1,154 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +using Werkr.Agent.Security; +using Werkr.Common.Models; + +namespace Werkr.Tests.Agent.Security; + +[TestClass] +public class PathAllowlistValidatorTests { + + private string _tempDir = null!; + + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + } + + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static PathAllowlistValidator CreateValidator( + AllowedPathsConfiguration config, + ILogger? logger = null ) { + IOptionsMonitor options = + new TestOptionsMonitor( config ); + return new PathAllowlistValidator( options, logger ?? NullLogger.Instance ); + } + + [TestMethod] + public void EnforceDisabled_AllPathsAllowed( ) { + AllowedPathsConfiguration config = new( ) { EnforceAllowlist = false }; + PathAllowlistValidator validator = CreateValidator( config ); + + Assert.IsTrue( validator.IsPathAllowed( @"C:\Windows\System32\cmd.exe" ) ); + Assert.IsTrue( validator.IsPathAllowed( "/etc/passwd" ) ); + } + + [TestMethod] + public void EnforceEnabled_AllowedPrefix_Permits( ) { + AllowedPathsConfiguration config = new( ) { + EnforceAllowlist = true, + Paths = [_tempDir], + }; + PathAllowlistValidator validator = CreateValidator( config ); + + string testFile = Path.Combine( _tempDir, "test.txt" ); + Assert.IsTrue( validator.IsPathAllowed( testFile ) ); + } + + [TestMethod] + public void EnforceEnabled_OutsidePrefix_Denies( ) { + AllowedPathsConfiguration config = new( ) { + EnforceAllowlist = true, + Paths = [_tempDir], + }; + PathAllowlistValidator validator = CreateValidator( config ); + + Assert.IsFalse( validator.IsPathAllowed( Path.Combine( Path.GetTempPath( ), "other-dir", "file.txt" ) ) ); + } + + [TestMethod] + public void EnforceEnabled_NoPaths_DeniesAll( ) { + AllowedPathsConfiguration config = new( ) { + EnforceAllowlist = true, + Paths = [], + }; + PathAllowlistValidator validator = CreateValidator( config ); + + Assert.IsFalse( validator.IsPathAllowed( _tempDir ) ); + } + + [TestMethod] + public void ValidatePath_OutsideAllowlist_ThrowsUnauthorized( ) { + AllowedPathsConfiguration config = new( ) { + EnforceAllowlist = true, + Paths = [_tempDir], + }; + PathAllowlistValidator validator = CreateValidator( config ); + + _ = Assert.ThrowsExactly( + ( ) => validator.ValidatePath( Path.Combine( Path.GetTempPath( ), "other-dir", "file.txt" ) ) ); + } + + [TestMethod] + public void ValidatePaths_MultiplePathsValidated( ) { + AllowedPathsConfiguration config = new( ) { + EnforceAllowlist = true, + Paths = [_tempDir], + }; + PathAllowlistValidator validator = CreateValidator( config ); + + string allowed = Path.Combine( _tempDir, "a.txt" ); + string denied = Path.Combine( Path.GetTempPath(), "other-dir", "b.txt" ); + + // First path is fine, second should throw + _ = Assert.ThrowsExactly( + ( ) => validator.ValidatePaths( allowed, denied ) ); + } + + [TestMethod] + public void TraversalAttack_Rejected( ) { + // Create a path that tries to escape via .. + AllowedPathsConfiguration config = new( ) { + EnforceAllowlist = true, + Paths = [_tempDir], + }; + PathAllowlistValidator validator = CreateValidator( config ); + + // Path.GetFullPath will resolve the .., making it outside the prefix + string attack = Path.Combine( _tempDir, "..", "escape.txt" ); + Assert.IsFalse( validator.IsPathAllowed( attack ) ); + } + + [TestMethod] + public void MultiplePrefixes_AnyMatch_Permits( ) { + string otherDir = Path.Combine( Path.GetTempPath(), $"werkr-test-{Guid.NewGuid()}" ); + _ = Directory.CreateDirectory( otherDir ); + try { + AllowedPathsConfiguration config = new( ) { + EnforceAllowlist = true, + Paths = [_tempDir, otherDir], + }; + PathAllowlistValidator validator = CreateValidator( config ); + + Assert.IsTrue( validator.IsPathAllowed( Path.Combine( _tempDir, "a.txt" ) ) ); + Assert.IsTrue( validator.IsPathAllowed( Path.Combine( otherDir, "b.txt" ) ) ); + } finally { + Directory.Delete( otherDir, recursive: true ); + } + } + + /// + /// Simple implementation for tests. + /// + private sealed class TestOptionsMonitor : IOptionsMonitor { + + public TestOptionsMonitor( T currentValue ) { + CurrentValue = currentValue; + } + + public T CurrentValue { get; } + + public T Get( string? name ) => CurrentValue; + + public IDisposable? OnChange( Action listener ) => null; + } +} diff --git a/src/Test/Werkr.Tests.Agent/Services/OperatorOutputAdapterTests.cs b/src/Test/Werkr.Tests.Agent/Services/OperatorOutputAdapterTests.cs new file mode 100644 index 0000000..0e24ebf --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Services/OperatorOutputAdapterTests.cs @@ -0,0 +1,122 @@ +using Werkr.Agent.Protos; +using Werkr.Agent.Services; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Cryptography; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Services; + +[TestClass] +public class OperatorOutputAdapterTests { + private byte[] _sharedKey = null!; + private const string TestKeyId = "test-key-1"; + + [TestInitialize] + public void TestInit( ) { + _sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); + } + + [TestMethod] + public async Task StreamToGrpc_ConvertsAllItems( ) { + List items = [ + OperatorOutput.Create( "Information", "msg1" ), + OperatorOutput.Create( "Warning", "msg2" ), + OperatorOutput.Create( "Error", "msg3" ), + ]; + MockServerStreamWriter writer = new( ); + + await OperatorOutputAdapter.StreamToGrpc( + ToAsyncEnumerable( items ), writer, _sharedKey, TestKeyId, CancellationToken.None ); + + Assert.HasCount( 3, writer.Messages ); + + GrpcLogMsg msg0 = PayloadEncryptor.DecryptFromEnvelope( writer.Messages[0], _sharedKey ); + GrpcLogMsg msg1 = PayloadEncryptor.DecryptFromEnvelope( writer.Messages[1], _sharedKey ); + GrpcLogMsg msg2 = PayloadEncryptor.DecryptFromEnvelope( writer.Messages[2], _sharedKey ); + + Assert.AreEqual( "Information", msg0.LogLevel ); + Assert.AreEqual( "msg1", msg0.Message ); + Assert.AreEqual( "Warning", msg1.LogLevel ); + Assert.AreEqual( "msg2", msg1.Message ); + Assert.AreEqual( "Error", msg2.LogLevel ); + Assert.AreEqual( "msg3", msg2.Message ); + } + + [TestMethod] + public async Task StreamToGrpc_PreservesTimestamps( ) { + OperatorOutput item = OperatorOutput.Create( "Information", "hello" ); + MockServerStreamWriter writer = new( ); + + await OperatorOutputAdapter.StreamToGrpc( + ToAsyncEnumerable( [item] ), writer, _sharedKey, TestKeyId, CancellationToken.None ); + + Assert.HasCount( 1, writer.Messages ); + GrpcLogMsg decrypted = PayloadEncryptor.DecryptFromEnvelope( writer.Messages[0], _sharedKey ); + Assert.AreEqual( item.Timestamp, decrypted.Timestamp ); + } + + [TestMethod] + public async Task StreamToGrpc_EncryptsOutput( ) { + OperatorOutput item = OperatorOutput.Create( "Information", "secret payload" ); + MockServerStreamWriter writer = new( ); + + await OperatorOutputAdapter.StreamToGrpc( + ToAsyncEnumerable( [item] ), writer, _sharedKey, TestKeyId, CancellationToken.None ); + + Assert.HasCount( 1, writer.Messages ); + + EncryptedEnvelope envelope = writer.Messages[0]; + Assert.IsFalse( envelope.Ciphertext.IsEmpty ); + Assert.AreEqual( TestKeyId, envelope.KeyId ); + + GrpcLogMsg decrypted = PayloadEncryptor.DecryptFromEnvelope( envelope, _sharedKey ); + Assert.AreEqual( "secret payload", decrypted.Message ); + } + + [TestMethod] + public async Task StreamToGrpc_EmptyStream_WritesNothing( ) { + MockServerStreamWriter writer = new( ); + + await OperatorOutputAdapter.StreamToGrpc( + ToAsyncEnumerable( [] ), writer, _sharedKey, TestKeyId, CancellationToken.None ); + + Assert.IsEmpty( writer.Messages ); + } + + [TestMethod] + public async Task StreamToGrpc_CancellationStopsStreaming( ) { + using CancellationTokenSource cts = new( ); + int itemCount = 0; + + async IAsyncEnumerable InfiniteStream( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default ) { + while (!ct.IsCancellationRequested) { + yield return OperatorOutput.Create( "Information", $"item {itemCount++}" ); + await Task.Yield( ); + if (itemCount >= 5) { + await cts.CancelAsync( ); + } + } + } + + MockServerStreamWriter writer = new( ); + + try { + await OperatorOutputAdapter.StreamToGrpc( + InfiniteStream( cts.Token ), writer, _sharedKey, TestKeyId, cts.Token ); + } catch (OperationCanceledException) { + // Expected + } + + Assert.IsLessThanOrEqualTo( 10, writer.Messages.Count, "Should have stopped streaming after cancellation." ); + } + + private static async IAsyncEnumerable ToAsyncEnumerable( + IEnumerable items ) { + foreach (OperatorOutput item in items) { + yield return item; + await Task.Yield( ); + } + } +} diff --git a/src/Test/Werkr.Tests.Agent/Services/PwshServiceTests.cs b/src/Test/Werkr.Tests.Agent/Services/PwshServiceTests.cs new file mode 100644 index 0000000..7a3a199 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Services/PwshServiceTests.cs @@ -0,0 +1,108 @@ +using Grpc.Core; + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +using Werkr.Agent.Operators; +using Werkr.Agent.Protos; +using Werkr.Agent.Services; +using Werkr.Common.Configuration; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Cryptography; +using Werkr.Data.Entities.Registration; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Services; + +[TestClass] +public class PwshServiceTests { + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + public async Task RunCommand_ValidEncryptedRequest_WritesEncryptedOutput( ) { + byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); + RegisteredConnection connection = CreateConnection( sharedKey ); + string keyId = connection.Id.ToString( ); + + PwshService service = new( + new PwshOperator( Options.Create( new AgentSettings { EnablePowerShell = true } ), NullLogger.Instance ), + Options.Create( new AgentSettings { EnablePowerShell = true } ), + NullLogger.Instance ); + + ShellRequest innerRequest = new( ) { Command = "Write-Output 'pwsh-service-test'" }; + EncryptedEnvelope request = PayloadEncryptor.EncryptToEnvelope( innerRequest, sharedKey, keyId ); + + MockServerStreamWriter stream = new( ); + TestServerCallContext context = TestServerCallContext.Create( cancellationToken: TestContext.CancellationToken ); + context.ExposedUserState["Connection"] = connection; + + await service.RunCommand( request, stream, context ); + + List decryptedMessages = [.. stream.Messages.Select( envelope => { + GrpcLogMsg logMsg = PayloadEncryptor.DecryptFromEnvelope( envelope, sharedKey ); + return logMsg.Message; + } )]; + + Assert.AreNotEqual( -1, decryptedMessages.FindIndex( m => m.Contains( "pwsh-service-test", StringComparison.OrdinalIgnoreCase ) ) ); + } + + [TestMethod] + public async Task RunCommand_WhenPowerShellDisabled_ThrowsUnimplemented( ) { + byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); + RegisteredConnection connection = CreateConnection( sharedKey ); + string keyId = connection.Id.ToString( ); + + PwshService service = new( + new PwshOperator( Options.Create( new AgentSettings { EnablePowerShell = false } ), NullLogger.Instance ), + Options.Create( new AgentSettings { EnablePowerShell = false } ), + NullLogger.Instance ); + + ShellRequest innerRequest = new( ) { Command = "Write-Output 'x'" }; + EncryptedEnvelope request = PayloadEncryptor.EncryptToEnvelope( innerRequest, sharedKey, keyId ); + + MockServerStreamWriter stream = new( ); + TestServerCallContext context = TestServerCallContext.Create( cancellationToken: TestContext.CancellationToken ); + context.ExposedUserState["Connection"] = connection; + + RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => + await service.RunCommand( request, stream, context ) ); + + Assert.AreEqual( StatusCode.Unimplemented, ex.StatusCode ); + } + + [TestMethod] + public async Task RunCommand_MissingConnection_ThrowsInternal( ) { + byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); + string keyId = Guid.NewGuid( ).ToString( ); + + PwshService service = new( + new PwshOperator( Options.Create( new AgentSettings { EnablePowerShell = true } ), NullLogger.Instance ), + Options.Create( new AgentSettings { EnablePowerShell = true } ), + NullLogger.Instance ); + + ShellRequest innerRequest = new( ) { Command = "Write-Output 'x'" }; + EncryptedEnvelope request = PayloadEncryptor.EncryptToEnvelope( innerRequest, sharedKey, keyId ); + + MockServerStreamWriter stream = new( ); + TestServerCallContext context = TestServerCallContext.Create( cancellationToken: TestContext.CancellationToken ); + + RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => + await service.RunCommand( request, stream, context ) ); + + Assert.AreEqual( StatusCode.Internal, ex.StatusCode ); + } + + private static RegisteredConnection CreateConnection( byte[] sharedKey ) { + return new RegisteredConnection { + Id = Guid.NewGuid( ), + ConnectionName = "Agent", + RemoteUrl = "https://localhost:5100", + OutboundApiKey = "outbound", + InboundApiKeyHash = "inbound", + SharedKey = sharedKey, + IsServer = false, + Status = Werkr.Common.Models.ConnectionStatus.Connected, + }; + } +} diff --git a/src/Test/Werkr.Tests.Agent/Services/SystemShellServiceTests.cs b/src/Test/Werkr.Tests.Agent/Services/SystemShellServiceTests.cs new file mode 100644 index 0000000..b7728ca --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Services/SystemShellServiceTests.cs @@ -0,0 +1,108 @@ +using Grpc.Core; + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +using Werkr.Agent.Operators; +using Werkr.Agent.Protos; +using Werkr.Agent.Services; +using Werkr.Common.Configuration; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Cryptography; +using Werkr.Data.Entities.Registration; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Services; + +[TestClass] +public class SystemShellServiceTests { + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + public async Task RunCommand_ValidEncryptedRequest_WritesEncryptedOutput( ) { + byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); + RegisteredConnection connection = CreateConnection( sharedKey ); + string keyId = connection.Id.ToString( ); + + SystemShellService service = new( + new SystemShellOperator( NullLogger.Instance ), + Options.Create( new AgentSettings { EnableSystemShell = true } ), + NullLogger.Instance ); + + ShellRequest innerRequest = new( ) { Command = "echo shell-service-test" }; + EncryptedEnvelope request = PayloadEncryptor.EncryptToEnvelope( innerRequest, sharedKey, keyId ); + + MockServerStreamWriter stream = new( ); + TestServerCallContext context = TestServerCallContext.Create( cancellationToken: TestContext.CancellationToken ); + context.ExposedUserState["Connection"] = connection; + + await service.RunCommand( request, stream, context ); + + List decryptedMessages = [.. stream.Messages.Select( envelope => { + GrpcLogMsg logMsg = PayloadEncryptor.DecryptFromEnvelope( envelope, sharedKey ); + return logMsg.Message; + } )]; + + Assert.AreNotEqual( -1, decryptedMessages.FindIndex( m => m.Contains( "shell-service-test", StringComparison.OrdinalIgnoreCase ) ) ); + } + + [TestMethod] + public async Task RunCommand_WhenSystemShellDisabled_ThrowsUnimplemented( ) { + byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); + RegisteredConnection connection = CreateConnection( sharedKey ); + string keyId = connection.Id.ToString( ); + + SystemShellService service = new( + new SystemShellOperator( NullLogger.Instance ), + Options.Create( new AgentSettings { EnableSystemShell = false } ), + NullLogger.Instance ); + + ShellRequest innerRequest = new( ) { Command = "echo x" }; + EncryptedEnvelope request = PayloadEncryptor.EncryptToEnvelope( innerRequest, sharedKey, keyId ); + + MockServerStreamWriter stream = new( ); + TestServerCallContext context = TestServerCallContext.Create( cancellationToken: TestContext.CancellationToken ); + context.ExposedUserState["Connection"] = connection; + + RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => + await service.RunCommand( request, stream, context ) ); + + Assert.AreEqual( StatusCode.Unimplemented, ex.StatusCode ); + } + + [TestMethod] + public async Task RunCommand_MissingConnection_ThrowsInternal( ) { + byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); + string keyId = Guid.NewGuid( ).ToString( ); + + SystemShellService service = new( + new SystemShellOperator( NullLogger.Instance ), + Options.Create( new AgentSettings { EnableSystemShell = true } ), + NullLogger.Instance ); + + ShellRequest innerRequest = new( ) { Command = "echo x" }; + EncryptedEnvelope request = PayloadEncryptor.EncryptToEnvelope( innerRequest, sharedKey, keyId ); + + MockServerStreamWriter stream = new( ); + TestServerCallContext context = TestServerCallContext.Create( cancellationToken: TestContext.CancellationToken ); + + RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => + await service.RunCommand( request, stream, context ) ); + + Assert.AreEqual( StatusCode.Internal, ex.StatusCode ); + } + + private static RegisteredConnection CreateConnection( byte[] sharedKey ) { + return new RegisteredConnection { + Id = Guid.NewGuid( ), + ConnectionName = "Agent", + RemoteUrl = "https://localhost:5100", + OutboundApiKey = "outbound", + InboundApiKeyHash = "inbound", + SharedKey = sharedKey, + IsServer = false, + Status = Werkr.Common.Models.ConnectionStatus.Connected, + }; + } +} diff --git a/src/Test/Werkr.Tests.Agent/Werkr.Tests.Agent.csproj b/src/Test/Werkr.Tests.Agent/Werkr.Tests.Agent.csproj new file mode 100644 index 0000000..a66737c --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Werkr.Tests.Agent.csproj @@ -0,0 +1,21 @@ + + + + Exe + false + true + true + false + + + + + + + + + + + + + diff --git a/src/Test/Werkr.Tests.Agent/packages.lock.json b/src/Test/Werkr.Tests.Agent/packages.lock.json new file mode 100644 index 0000000..9350d5b --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/packages.lock.json @@ -0,0 +1,1638 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "MSTest": { + "type": "Direct", + "requested": "[4.1.0, )", + "resolved": "4.1.0", + "contentHash": "2bk47yg7HcHRyf6Zf0XgCZicTVTQj4D5lonYTO7lWMxCQB+x66VrQNc2dADBfzthKXfHaA37m8i+VV5h6SbWiA==", + "dependencies": { + "MSTest.TestAdapter": "4.1.0", + "MSTest.TestFramework": "4.1.0", + "Microsoft.NET.Test.Sdk": "18.0.1", + "Microsoft.Testing.Extensions.CodeCoverage": "18.4.1", + "Microsoft.Testing.Extensions.TrxReport": "2.1.0" + } + }, + "Grpc.AspNetCore.Server": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0" + } + }, + "Grpc.AspNetCore.Server.ClientFactory": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==", + "dependencies": { + "Grpc.AspNetCore.Server": "2.76.0", + "Grpc.Net.ClientFactory": "2.76.0" + } + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==", + "dependencies": { + "Grpc.Core.Api": "2.76.0" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Json.More.Net": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "izscdjjk8EAHDBCjyz7V7n77SzkrSjh/hUGV6cyR6PlVdjYDh5ohc8yqvwSqJ9+6Uof8W6B24dIHlDKD+I1F8A==" + }, + "JsonPointer.Net": { + "type": "Transitive", + "resolved": "5.0.2", + "contentHash": "H/OtixKadr+ja1j7Fru3WG56V9zP0AKT1Bd0O7RWN/zH1bl8ZIwW9aCa4+xvzuVvt4SPmrvBu3G6NpAkNOwNAA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Json.More.Net": "2.0.1.2" + } + }, + "JsonSchema.Net": { + "type": "Transitive", + "resolved": "7.2.3", + "contentHash": "O3KclMcPVFYTZsTeZBpwtKd/lYrNc3AFR+xi9j3Q4CfhDufOUx25TMMWJOcFRrqVklvKQ4Kl+0UhlNX1iDGoRw==", + "dependencies": { + "JsonPointer.Net": "5.0.0" + } + }, + "Markdig.Signed": { + "type": "Transitive", + "resolved": "0.38.0", + "contentHash": "zfi6kNm5QJnsCGm5a0hMG2qw8juYbOfsS4c1OuTcqkbYQUCdkam6d6Nt7nPIrbV4D+U7sHChidSQlg+ViiMPuw==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.11.0", + "contentHash": "djf8ujmqYImFgB04UGtcsEhHrzVqzHowS+EEl/Yunc5LdrYrZhGBWUTXoCF0NzYXJxtfuD+UVQarWpvrNc94Qg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.4" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.11.0", + "contentHash": "6XYi2EusI8JT4y2l/F3VVVS+ISoIX9nqHsZRaG6W5aFeJ5BEuBosHfT/ABb73FN0RZ1Z3cj2j7cL28SToJPXOw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.4", + "Microsoft.CodeAnalysis.Common": "[4.11.0]" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA==" + }, + "Microsoft.DiaSymReader": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "QcZrCETsBJqy/vQpFtJc+jSXQ0K5sucQ6NUFbTNVHD4vfZZOwjZ/3sBzczkC4DityhD3AVO/+K/+9ioLs1AgRA==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Features": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "djFt1Jt+2uREWWVQiiA4ilYBDtHHY7nK08c5K8xBD9+XFNw3KDVprylrMkH08bZGK3ZHRAkS7JDV9srfLrcm/g==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "M5gWob3dtzlF14oto1lR1ZuSJrR0gGc+obv7zY9LGmX5y3Ndpve29MrrjqJW/m4CFud4TE/KFUuHjjtwxhCO8g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.Telemetry": "10.3.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PBlaoYeusaxNYyN4WFjzcXWlUDSvLUPxy/e6oP1SONOOYA/oBWT2uBmFGJMV9VTtXiXXxCB39LqlYWbsWE4UKA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "dQKlVXzqflsv5X8iDlAN5YmTL1GcLCrOLKo1s9PNdfjqxeu0S/jmWTfiLGno+8+o1qFL3+VFAH5/ftmypN+sPw==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.ServiceDiscovery.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Features": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.Management.Infrastructure": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "cGZi0q5IujCTVYKo9h22Pw+UwfZDV82HXO8HTxMG2HqntPlT3Ls8jY6punLp4YzCypJNpfCAu2kae3TIyuAiJw==", + "dependencies": { + "Microsoft.Management.Infrastructure.Runtime.Unix": "3.0.0", + "Microsoft.Management.Infrastructure.Runtime.Win": "3.0.0" + } + }, + "Microsoft.Management.Infrastructure.CimCmdlets": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "p2nh2bDZGeAsOLd/QwRrZGahPV1Jy1Z0LNA/ZSqpyN8Cp31qh1UOfpmq4rss5P5deuygAN6DTLn96LY5oEDQpg==", + "dependencies": { + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.Management.Infrastructure.Runtime.Unix": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "QZE3uEDvZ0m7LabQvcmNOYHp7v1QPBVMpB/ild0WEE8zqUVAP5y9rRI5we37ImI1bQmW5pZ+3HNC70POPm0jBQ==" + }, + "Microsoft.Management.Infrastructure.Runtime.Win": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "uwMyWN33+iQ8Wm/n1yoPXgFoiYNd0HzJyoqSVhaQZyJfaQrJR3udgcIHjqa1qbc3lS6kvfuUMN4TrF4U4refCQ==" + }, + "Microsoft.NET.Test.Sdk": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==", + "dependencies": { + "Microsoft.CodeCoverage": "18.0.1", + "Microsoft.TestPlatform.TestHost": "18.0.1" + } + }, + "Microsoft.PowerShell.Commands.Diagnostics": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "sRBHmXm2Ivy6pyAI2OX5PJ1DXbmmA1/OusFEXwdWWEjjiZ0prul3POc3GJoiMSn6WF5dJ6xw53MKZrkvu4uCgA==", + "dependencies": { + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.PowerShell.Commands.Management": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "OhkLYDIf2xeexTWi+3yBRIrGMCpBBDPGAzKAp0wLCj3IE1D2H1Uj4XEE67y69eLFx7jxVwy2Er9hoTt5joECig==", + "dependencies": { + "Microsoft.PowerShell.Security": "7.5.4", + "System.Diagnostics.EventLog": "9.0.10", + "System.ServiceProcess.ServiceController": "9.0.10" + } + }, + "Microsoft.PowerShell.Commands.Utility": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "lvVh2zHEC2EnBImCRpu9b+5qqngE5o76gVI1NzIfReBXNtsym51XmX/kCrN0INm98CN3GoxTBa7WTcTJC1H3dw==", + "dependencies": { + "Json.More.Net": "2.0.2", + "JsonPointer.Net": "5.0.2", + "JsonSchema.Net": "7.2.3", + "Markdig.Signed": "0.38.0", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "4.11.0", + "Microsoft.PowerShell.MarkdownRender": "7.2.1", + "Microsoft.Win32.SystemEvents": "9.0.10", + "System.Drawing.Common": "9.0.10", + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.PowerShell.ConsoleHost": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "0U3DkO631KXj5m4jfsKQrUT795ZvZZAzvjNTNdhO4YNukwSSSzJUTszVVE2NXwbkQZHuAoUjPTwicINZJ87OoQ==", + "dependencies": { + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.PowerShell.CoreCLR.Eventing": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "1xyl5hcWKs5IDFO1ZWXSoVLPN78CJpo6GykVg3F/kNHkldixODi6yz1bbVmyEAMC64AvA3ZKSs/AZaGNoKTI+w==", + "dependencies": { + "System.Diagnostics.EventLog": "9.0.10" + } + }, + "Microsoft.PowerShell.MarkdownRender": { + "type": "Transitive", + "resolved": "7.2.1", + "contentHash": "o5oUwL23R/KnjQPD2Oi49WAG5j4O4VLo1fPRSyM/aq0HuTrY2RnF4B3MCGk13BfcmK51p9kPlHZ1+8a/ZjO4Jg==", + "dependencies": { + "Markdig.Signed": "0.31.0" + } + }, + "Microsoft.PowerShell.Native": { + "type": "Transitive", + "resolved": "7.4.0", + "contentHash": "FlaJ3JBWhqFToYT0ycMb/Xxzoof7oTQbNyI4UikgubC7AMWt5ptBNKjIAMPvOcvEHr+ohaO9GvRWp3tiyS3sKw==" + }, + "Microsoft.PowerShell.Security": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "k/TMcn7ETkq91qhzncGbHthOEzZjGzcq6U6E4exyJRsRqe2MqaRXGrPifiCXDJ6I/dSQOclSpqFSqE/SWVUFdQ==", + "dependencies": { + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.Security.Extensions": { + "type": "Transitive", + "resolved": "1.4.0", + "contentHash": "MnHXttc0jHbRrGdTJ+yJBbGDoa4OXhtnKXHQw70foMyAooFtPScZX/dN+Nib47nuglc9Gt29Gfb5Zl+1lAuTeA==" + }, + "Microsoft.Testing.Extensions.CodeCoverage": { + "type": "Transitive", + "resolved": "18.4.1", + "contentHash": "l1VZM9dg9s76L5D288ipAT4HRYDJ6Vxh8wX20gfS9VnpueedRfN4/aGNn4oA1g6pwq2WSM3Ci7IoSSGPiqu+WQ==", + "dependencies": { + "Microsoft.DiaSymReader": "2.0.0", + "Microsoft.Extensions.DependencyModel": "8.0.2", + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "5TwgTx2u7k9Al/xbZ18QXq4Hdy2xewkVTI6K3sk+jY2ykqUkIKNuj7rFu3GOV5KnEUkevhw6eZcyZs77STHJIA==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "cXmP225WcMLLOSrW8xekaNhfzdBwXX3cbXbE5qSzmLbK0KZe3z8rAObKj70FWiPPPzm2W22x0ZW93gsmAfK6Mg==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.1.0", + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "D8xmIJYQFJ6D49Rx5/vPrkZZxb338Jkew+eSqZLBfBiWKw4QZKy3i1BOXiLfz0lOmaNErwDz/YWRojCdNl+B9Q==", + "dependencies": { + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Extensions.VSTestBridge": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "bNRIEA2YoGr+Y+7LHdA7i1U80+7BAdf4K4Qh4Kx6eKkoBK/NV7QpoMg+GWPP0/eqAFzuUmUOIPVZ87Oo0Vyxmw==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Microsoft.Testing.Extensions.Telemetry": "2.1.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.1.0", + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "aHkjNTGIA+Zbdw6RJgSFrbDrCjO0CgqpElqYcvkRSeUhBv2bKarnvU3ep786U7UqrPlArT/B7VmImRibJD0Zrg==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "UpfPebXQtHGrWz21+YLHmJSm+5zsuPE9U9pfdCtoB+67g75fDmWlNgpkH2ZmdVhSwkjNIed9Icg8Iu63z2ei5Q==", + "dependencies": { + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Win32.Registry.AccessControl": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "ZYHfH0wgTa4usqMMetFYezSjfkQaMat83b/Ykz1q4qSx1h/OiXFb8ZSsn3ZKttHcxe1bn5m/+Zjz9deVT45L8w==" + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "P1CEtsxar/RhfoH3r1vc9ra28LLVYphpcFBxyRIEMM/jP3qh4j9TU4sWH2RUhMZX+GbFxZ+zz1oSP2n9MwjshA==" + }, + "Microsoft.Windows.Compatibility": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "nCkAfadYeJNfJE/RoKGKFlIHVzovN6/DhLm4ebaCBiLWnP6R/fe22n3BWWqlkT2ignu+GTBrkNLs64e8yCCmGw==", + "dependencies": { + "Microsoft.Win32.Registry.AccessControl": "9.0.10", + "Microsoft.Win32.SystemEvents": "9.0.10", + "System.CodeDom": "9.0.10", + "System.ComponentModel.Composition": "9.0.10", + "System.ComponentModel.Composition.Registration": "9.0.10", + "System.Configuration.ConfigurationManager": "9.0.10", + "System.Data.Odbc": "9.0.10", + "System.Data.OleDb": "9.0.10", + "System.Data.SqlClient": "4.9.0", + "System.Diagnostics.EventLog": "9.0.10", + "System.Diagnostics.PerformanceCounter": "9.0.10", + "System.DirectoryServices": "9.0.10", + "System.DirectoryServices.AccountManagement": "9.0.10", + "System.DirectoryServices.Protocols": "9.0.10", + "System.Drawing.Common": "9.0.10", + "System.IO.Packaging": "9.0.10", + "System.IO.Ports": "9.0.10", + "System.Management": "9.0.10", + "System.Reflection.Context": "9.0.10", + "System.Runtime.Caching": "9.0.10", + "System.Security.Cryptography.Pkcs": "9.0.10", + "System.Security.Cryptography.ProtectedData": "9.0.10", + "System.Security.Cryptography.Xml": "9.0.10", + "System.Security.Permissions": "9.0.10", + "System.ServiceModel.Duplex": "4.10.3", + "System.ServiceModel.Http": "4.10.3", + "System.ServiceModel.NetTcp": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3", + "System.ServiceModel.Security": "4.10.3", + "System.ServiceModel.Syndication": "9.0.10", + "System.ServiceProcess.ServiceController": "9.0.10", + "System.Speech": "9.0.10", + "System.Web.Services.Description": "4.10.3" + } + }, + "Microsoft.WSMan.Management": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "VaLrRXOuIlflS1zonDAbuKdADLojCeSdDy4d4vILa1l2SO3Yaheh3gGP3g4emPCeVDN75ZokigH8Ehe0OeNO1A==", + "dependencies": { + "Microsoft.WSMan.Runtime": "7.5.4", + "System.Diagnostics.EventLog": "9.0.10", + "System.Management.Automation": "7.5.4", + "System.ServiceProcess.ServiceController": "9.0.10" + } + }, + "Microsoft.WSMan.Runtime": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "Kw5tys1LdJRl/Sn3qT5Os0VJev1o5TGPPfrd7SfxUFiHLcYeiO0IQGFpumZ9SXr4FxPot1125iu3l2a2VEEBZw==" + }, + "MSTest.Analyzers": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "4ElL/aqomiUInr090VN4udqz46AuszXLrifHkLrgj0zb7na8eAoyUQt3BwDLTcGd1bSkmk3SfD02rZtKU+ZiqQ==" + }, + "MSTest.TestAdapter": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "bRW1Hftwq0XbcVExcAbj4YAfSZDRAziL0mygDkPBvaUe2nSsWFQIatze5lHVjPFJMvSFgWnItku4pguIy5FowQ==", + "dependencies": { + "MSTest.TestFramework": "4.1.0", + "Microsoft.Testing.Extensions.VSTestBridge": "2.1.0", + "Microsoft.Testing.Platform.MSBuild": "2.1.0" + } + }, + "MSTest.TestFramework": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "BzpvsK+CRbk6khwY62h+7HfYzIxtJXyPv9tOI9T90cy5CVy+WI1JkN4ZaNL4Dobqb6dywSwabLTIbPZKpdrr+A==", + "dependencies": { + "MSTest.Analyzers": "4.1.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "OpenTelemetry.Api": "1.15.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, + "runtime.android-arm.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "KUeHD0wRFCTS9QHantD5Cv/RzDzVY/mQP1Z/eKLtlX5A5SZvsqeomAoayPdh/QmgSzquoHeIDMAMp8VVU+Xzag==" + }, + "runtime.android-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "b+z8JoBrZ5TMXiXeh0s+s9/uIVx6PmulEuMaN81JLM68aAb4DWHi7t5CL+8bWJhsFhd8VAYoZ9pi5miNTFPeuA==" + }, + "runtime.android-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "K7u+/G2gPoRLNc974p1Tnp44VRLlpQWZrKEQofBTpyJZPgd46ayvXayqT4jyGodG4O6Q6+yY2pYUYlqv1K2l6w==" + }, + "runtime.android-x86.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "TGT4P40ockzrZf/K46A3VAl2dC2PWAS6WhqqtZJbH5G7XgBMX/FoaoY6DtFtz6u7RVl4zhjdG3QWXEv/u/1Hlg==" + }, + "runtime.linux-arm.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "Y2EEaUtO1JolypkFcqgsDxjmOleHa7d9OxBY4Osw5vIdQpOfP0Qj30czQfkN7cZTQH8NxsSr5WawVbk5yFabFg==" + }, + "runtime.linux-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "nIFQGoz22wdgtmS4Ce+weqGUBh1kpO4XbNEgCU01+7P/+yZAb+gbRSeJUyUmCPhyW0S8FhX1xgJDH/SiJgP05Q==" + }, + "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "jV3esGC4j69yPlRzj50EbJq4syweBm4rOWKYJ3nWCMbVzTW1YQ2o4QhiVjCDOEKEf6q5eVGEaa6fyVXQ/K95Hw==" + }, + "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "UfoiSWuf75mYJPknRXSezDoFYaCp5dWoUjASjg6gQSa7FD2G59Mee6vMEzHFS+x8N+H8oNnL9TCIZUD4/8e/2Q==" + }, + "runtime.linux-musl-arm.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "CWwqjMVVtYiW4I9wk9YuUaSxxkPZKZ/BKj5ppAsIZv4X9u/dyh8+Qbj3Fly61uUXpGxXU4QFQhYuPi5pJTAOBA==" + }, + "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "NxMtoYPV8lreQggsXWsXXRF/djycKieThc4O2kxGB6EgjsiRDuNdnbODV0lWGV6v4mn08uQvuOhmBP5ZYpVdkA==" + }, + "runtime.linux-musl-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "ZNQ/D4lUGxTbfGAZRWP4vp7tCLqvInit03YXAiFXDWh/DnMEosBjrwcu8vbWgSsF01DUbyZQai8lwAStIZWo8w==" + }, + "runtime.linux-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "mOTksL8qwN9EiWz71Dzhc98iQhKmtHlWH5GbhiCJ+ES2ei1HLr2bXSNMrXRd5s0Wfzg6xeQmT5M9umcgtv8Bzg==" + }, + "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "aKoLfCdLoQGhPh2VYBn6sn1kDuwgtXKJ4D2Ql/2WLCGCuXestpxLBS0JhSVBFSp1HrFdazj4aSwpYurtes+1Gg==" + }, + "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "B+boSbUptH2fiKiUXBIu5hlQ3oH+nAdPY9TNmpU10nuoFW/DNz21fCXY3UOIz9oRXp5Ao2b7RlurgpBl0AgoOQ==" + }, + "runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "A8v6PGmk+UGbfWo5Ixup0lPM4swuSwOiayJExZwKIOjTlFFQIsu3QnDXECosBEyrWSPryxBVrdqtJyhK3BaupQ==", + "dependencies": { + "runtime.win-arm64.runtime.native.System.Data.SqlClient.sni": "4.4.0", + "runtime.win-x64.runtime.native.System.Data.SqlClient.sni": "4.4.0", + "runtime.win-x86.runtime.native.System.Data.SqlClient.sni": "4.4.0" + } + }, + "runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "7AzXN+J8PkTVctfymH+tEaAmj0wNKFPyACqp5cYff0DrHxnsQhv7xtRWxJRrQ0azOAFGR1mhWN4aM1QkbQQ0Rw==", + "dependencies": { + "runtime.android-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-x86.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.osx-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.osx-x64.runtime.native.System.IO.Ports": "9.0.10" + } + }, + "runtime.osx-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "m+gRRrmCTwP30YiVnFeZg/zRWgzVcOlN28cIPMkK11C9UU60waLknTnRLlQUagIkWaCDifKJCB6wtEeca5QiMA==" + }, + "runtime.osx-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "89HgF1Oplzjomn0BLeqJxEC17d//zmbs7CMKT12ZvjvFMvpMFO8uQUZ9xRIh91rM0ByfbhSobe2IRezjpeDNlg==" + }, + "runtime.win-arm64.runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "LbrynESTp3bm5O/+jGL8v0Qg5SJlTV08lpIpFesXjF6uGNMWqFnUQbYBJwZTeua6E/Y7FIM1C54Ey1btLWupdg==" + }, + "runtime.win-x64.runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "38ugOfkYJqJoX9g6EYRlZB5U2ZJH51UP8ptxZgdpS07FgOEToV+lS11ouNK2PM12Pr6X/PpT5jK82G3DwH/SxQ==" + }, + "runtime.win-x86.runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "YhEdSQUsTx+C8m8Bw7ar5/VesXvCFMItyZF7G1AUY+OM0VPZUOeAVpJ4Wl6fydBGUYZxojTDR3I6Bj/+BPkJNA==" + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "00dAIR9Zx+F+AaipjaQmudX3VVpzYvT0bKVD3WcJq6om6pKNrldnp5bSR0VV6IlwDBa1HObGD+sTFaT/I9bBng==" + }, + "System.ComponentModel.Composition": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "tLJKLlc3VsjTLZ4aAwKicKfLKTAzTSSod+T6TWQSjmmA2JMgVvsU5QA2Ka2+Gq2M8poLaxY2dAipFsJen+ZI/g==" + }, + "System.ComponentModel.Composition.Registration": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "H+iSxY02ucdevQa+4jc5disuSgiLom2gUrdATFmVFWc/1De5HBtssVdcar2mxDbtT5IBKiMvwXVHrnl5jmaQtw==", + "dependencies": { + "System.ComponentModel.Composition": "9.0.10", + "System.Reflection.Context": "9.0.10" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "5CBhl5dWmckKEtvk8F6GXtmHxNBoqAC8xILxIntNm7AzHiXQ09CXSLhncIJ/cQWaiNYzLjHZCgtMfx9tkCKHdA==", + "dependencies": { + "System.Diagnostics.EventLog": "9.0.10", + "System.Security.Cryptography.ProtectedData": "9.0.10" + } + }, + "System.Data.Odbc": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "1GjZfLbeSdfHhKUFhk4oU6f3PSF2DOFILTPLHDuC8Pj7UWvwnl8a+H7LDtwEqIJuZ0O2n0rMjydm+Fn67u0G2w==" + }, + "System.Data.OleDb": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "LwiN01NosLlqowmrD1ej1qM1O3GVZeQZzbWrTwYLyeQUGyTVt8yVsTgsRnIJmKny1ENdVcQ9WhKUjzBnh37fsQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "9.0.10", + "System.Diagnostics.PerformanceCounter": "9.0.10" + } + }, + "System.Data.SqlClient": { + "type": "Transitive", + "resolved": "4.9.0", + "contentHash": "j4KJO+vC62NyUtNHz854njEqXbT8OmAa5jb1nrGfYWBOcggyYUQE0w/snXeaCjdvkSKWuUD+hfvlbN8pTrJTXg==", + "dependencies": { + "runtime.native.System.Data.SqlClient.sni": "4.4.0" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "Jc+az1pTMujPLDn2j5eqSfzlO7j/T1K/LB7THxdfRWOxujE4zaitUqBs7sv1t6/xmmvpU6Xx3IofCs4owYH0yQ==" + }, + "System.Diagnostics.PerformanceCounter": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "35eXaOLXv8ATGDVr946gK0sNEEOwuFzhjFjTQftWh0swhLiyIjAD1pu17tu/SVENpKPZwqJ2e7IIcLpIs0GEzQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "9.0.10" + } + }, + "System.DirectoryServices": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "dlSYvBLD/XlW2y7hJA+INfcRRtkouFSEcYSVoYmxwfurVdYJ088+PUYf8kgszAp3cThpMPAPVhNHl1lMYrv9kw==" + }, + "System.DirectoryServices.AccountManagement": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "5sNlMrUPhEH8gmosdAz2ZuKA4S4fBdnkpgw5C9IIgyZzy8xg8wPj9aX5oBhoep48tqDVz0++DBWJxJsi4UjT+A==", + "dependencies": { + "System.Configuration.ConfigurationManager": "9.0.10", + "System.DirectoryServices": "9.0.10", + "System.DirectoryServices.Protocols": "9.0.10" + } + }, + "System.DirectoryServices.Protocols": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "nyJa6GTsPxNYt08Ssl9xHXLyDGozVkmsWgmAegUw9+4TBvS8BO1oV69XlkbyF+oJ6qR4+VPy7lgDWUMapvQfUg==" + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "FDakPhIcxHnhslLiz4ZQ+ALpHRpCU3zOep9Mcq+4hL23XwQrzmgJNYvf1tH4kJ/V36wO/ZhRr8nOfiz26P3wKg==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "9.0.10" + } + }, + "System.IO.Packaging": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "dKlnLbyOKFCLa5rda8yUU6M0HhVLMkB7rf9lEWnXVtHdNlq9A/fJmt7s/OhwbYaUfOO8rxshpQLyPn0Pv1a2lQ==" + }, + "System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "jMvwu+NOk/+vlOzTp9vpxIeGq+yRA+3EbkmpLMs37AAy9cI8YlY/ntTHL00w26Tvu6cIkx0/TdjmeHm0l99Nqw==", + "dependencies": { + "runtime.native.System.IO.Ports": "9.0.10" + } + }, + "System.Management": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "kJY2C6MjKSqfRkEnc8gn4Jth81Anrgxxpu0MffjEadfpp0Ll/gdGpYnDhRWZd+iFttkfZC0uCjFmCrZARRqq4w==", + "dependencies": { + "System.CodeDom": "9.0.10" + } + }, + "System.Management.Automation": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "kHvz4Gc2sQ670KNU+CMsCmoxSM+hO+qW9ujyf3MbBuDImuKeHL8oo2gq4kZpuncO/MSeOTstx3pW8YE6jqIZYA==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.22.0", + "Microsoft.Management.Infrastructure": "3.0.0", + "Microsoft.PowerShell.CoreCLR.Eventing": "7.5.4", + "Microsoft.PowerShell.Native": "7.4.0", + "Microsoft.Security.Extensions": "1.4.0", + "Microsoft.Win32.Registry.AccessControl": "9.0.10", + "Newtonsoft.Json": "13.0.4", + "System.CodeDom": "9.0.10", + "System.Configuration.ConfigurationManager": "9.0.10", + "System.Diagnostics.EventLog": "9.0.10", + "System.DirectoryServices": "9.0.10", + "System.Management": "9.0.10", + "System.Security.Cryptography.Pkcs": "9.0.10", + "System.Security.Cryptography.ProtectedData": "9.0.10", + "System.Security.Permissions": "9.0.10", + "System.Windows.Extensions": "9.0.10" + } + }, + "System.Net.Http.WinHttpHandler": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "D7CvYoTJPp/gDP3CMKxyUXUpfs8pFi4mQs+USHlT3Bqq6b83Lqe7gOn/dVPVZ78d2/cimxcqnpB9N2f1cDllWg==" + }, + "System.Private.ServiceModel": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "BcUV7OERlLqGxDXZuIyIMMmk1PbqBblLRbAoigmzIUx/M8A+8epvyPyXRpbgoucKH7QmfYdQIev04Phx2Co08A==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "Microsoft.Extensions.ObjectPool": "5.0.10", + "System.Security.Cryptography.Xml": "6.0.1" + } + }, + "System.Reflection.Context": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "Dv7cY++FuibtTyQfWR7ZVMjdtblYkRH6po+UiyBsUwNri2T+afSqwpZq4F2zsVGxtsNsZpXbrJCDs4PxvwxMrQ==" + }, + "System.Runtime.Caching": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "WFKbtzR8mfIZWeQlYGtyjMcse3DoNR0zLsNAev2dDYM8pY945EzzLPO84qnVa+BIEDF1woD8+TtboWSh65U2DQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "9.0.10" + } + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "Pg7QZz80fOJZrtJnAdEAIpeor8q7F1ofwXGYgLNr4dR8Mqf2l7lfeTaodQkRetrj+ClQwVVYoyi6g2eOsmstFw==" + }, + "System.Security.Cryptography.Xml": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "kkEBXInhetgK1+E0NzDSz4S2Yh3wpivGf1A7I88dN4SYINGrQnGciGDJj1RTgsE/zFeJNlAZhXs4XSqn7q8AhQ==", + "dependencies": { + "System.Security.Cryptography.Pkcs": "9.0.10" + } + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "uqzXSkn2nx9nplIdayurMtbLcQQdOGd7TmIQ+X5P65+QWT2S+1aUZfJuH2f+Blr/4W6wxMkiX9aKzLk7lfMZFQ==", + "dependencies": { + "System.Windows.Extensions": "9.0.10" + } + }, + "System.ServiceModel.Duplex": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "IZ8ZahvTenWML7/jGUXSCm6jHlxpMbcb+Hy+h5p1WP9YVtb+Er7FHRRGizqQMINEdK6HhWpD6rzr5PdxNyusdg==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3" + } + }, + "System.ServiceModel.Http": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "hodkn0rPTYmoZ9EIPwcleUrOi1gZBPvU0uFvzmJbyxl1lIpVM5GxTrs/pCETStjOXCiXhBDoZQYajquOEfeW/w==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3" + } + }, + "System.ServiceModel.NetTcp": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "tP7GN7ehqSIQEz7yOJEtY8ziTpfavf2IQMPKa7r9KGQ75+uEW6/wSlWez7oKQwGYuAHbcGhpJvdG6WoVMKYgkw==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3" + } + }, + "System.ServiceModel.Primitives": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "aNcdry95wIP1J+/HcLQM/f/AA73LnBQDNc2uCoZ+c1//KpVRp8nMZv5ApMwK+eDNVdCK8G0NLInF+xG3mfQL+g==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3" + } + }, + "System.ServiceModel.Security": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "vqelKb7DvP2inb6LDJ5Igi8wpOYdtLXn5luDW5qEaqkV2sYO1pKlVYBpr6g6m5SevzbdZlVNu67dQiD/H6EdGQ==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3" + } + }, + "System.ServiceModel.Syndication": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "jWOXgKi51ULlPDi+YIWsZglIYUYC1DixAs2j6xdy8fzhuxvXO82yUEXv4wFziqzoG1FmTAV/uv5psxb+3MqB7w==" + }, + "System.ServiceProcess.ServiceController": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "dmH+qHQ5wMjvEI0M2s6J+vmaU9L9ID2D9DWMFa7FiTfINfo3e3zeL4ljX7Dg5gCnFIULPFip2ej2iIAC3X6MFw==", + "dependencies": { + "System.Diagnostics.EventLog": "9.0.10" + } + }, + "System.Speech": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "rtbgAR0AD2yij7tqh/TJFAvsr1KN+Q8hb8JUcAN7uLh5EAkQ8Z4o7bFTQpcZDPec3/KsBFPHZNQS0nTLHEdmwQ==" + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "System.Web.Services.Description": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "6pwntR5vqLOzUPU9GcLVNEASAVf0GFeXoRF4p/SWIiU3073ZbWJ6dJM5cpXgylcbJDjlwPqNx9f5Y4Od0cNfDA==" + }, + "System.Windows.Extensions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "6I+OzjcTx2gtZotjDQXEhWdkfPVxRvT9r9nFWsgt9Of6GwLt9szpIlxx0z2dP3dprg6K3zRU/5bbig+zoVKpfg==" + }, + "werkr.agent": { + "type": "Project", + "dependencies": { + "Grpc.AspNetCore": "[2.76.0, )", + "Microsoft.PowerShell.SDK": "[7.5.4, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.File": "[7.0.0, )", + "Serilog.Sinks.OpenTelemetry": "[4.2.0, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Core": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )", + "Werkr.ServiceDefaults": "[1.0.0, )" + } + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.AspNetCore.Authorization": "[10.0.3, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.core": { + "type": "Project", + "dependencies": { + "Grpc.Net.Client": "[2.76.0, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.3, )", + "System.Security.Cryptography.ProtectedData": "[10.0.3, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )" + } + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "werkr.servicedefaults": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Grpc.AspNetCore": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==", + "dependencies": { + "Google.Protobuf": "3.31.1", + "Grpc.AspNetCore.Server.ClientFactory": "2.76.0", + "Grpc.Tools": "2.76.0" + } + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Grpc.Net.ClientFactory": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==", + "dependencies": { + "Grpc.Net.Client": "2.76.0", + "Microsoft.Extensions.Http": "8.0.0" + } + }, + "Microsoft.AspNetCore.Authorization": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.3.0", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Resilience": "10.3.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.ServiceDiscovery": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Microsoft.PowerShell.SDK": { + "type": "CentralTransitive", + "requested": "[7.5.4, )", + "resolved": "7.5.4", + "contentHash": "VjRoL4Eja88vOpEflx17ijURIZ3Q5780PTAD8XYhXmlMca6uUghh3qwhpWOQJF8OpYOLUiA6fRPRvoayX2BSXA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "Microsoft.Extensions.ObjectPool": "8.0.21", + "Microsoft.Management.Infrastructure.CimCmdlets": "7.5.4", + "Microsoft.PowerShell.Commands.Diagnostics": "7.5.4", + "Microsoft.PowerShell.Commands.Management": "7.5.4", + "Microsoft.PowerShell.Commands.Utility": "7.5.4", + "Microsoft.PowerShell.ConsoleHost": "7.5.4", + "Microsoft.PowerShell.Security": "7.5.4", + "Microsoft.WSMan.Management": "7.5.4", + "Microsoft.Win32.Registry.AccessControl": "9.0.10", + "Microsoft.Win32.SystemEvents": "9.0.10", + "Microsoft.Windows.Compatibility": "9.0.10", + "System.CodeDom": "9.0.10", + "System.ComponentModel.Composition": "9.0.10", + "System.ComponentModel.Composition.Registration": "9.0.10", + "System.Configuration.ConfigurationManager": "9.0.10", + "System.Data.Odbc": "9.0.10", + "System.Data.OleDb": "9.0.10", + "System.Data.SqlClient": "4.9.0", + "System.Diagnostics.EventLog": "9.0.10", + "System.Diagnostics.PerformanceCounter": "9.0.10", + "System.DirectoryServices": "9.0.10", + "System.DirectoryServices.AccountManagement": "9.0.10", + "System.DirectoryServices.Protocols": "9.0.10", + "System.Drawing.Common": "9.0.10", + "System.IO.Packaging": "9.0.10", + "System.IO.Ports": "9.0.10", + "System.Management": "9.0.10", + "System.Management.Automation": "7.5.4", + "System.Net.Http.WinHttpHandler": "9.0.10", + "System.Private.ServiceModel": "4.10.3", + "System.Reflection.Context": "9.0.10", + "System.Runtime.Caching": "9.0.10", + "System.Security.Cryptography.Pkcs": "9.0.10", + "System.Security.Cryptography.ProtectedData": "9.0.10", + "System.Security.Cryptography.Xml": "9.0.10", + "System.Security.Permissions": "9.0.10", + "System.ServiceModel.Duplex": "4.10.3", + "System.ServiceModel.Http": "4.10.3", + "System.ServiceModel.NetTcp": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3", + "System.ServiceModel.Security": "4.10.3", + "System.ServiceProcess.ServiceController": "9.0.10", + "System.Speech": "9.0.10", + "System.Web.Services.Description": "8.0.0", + "System.Windows.Extensions": "9.0.10", + "runtime.android-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-x86.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.native.System.IO.Ports": "9.0.10", + "runtime.osx-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.osx-x64.runtime.native.System.IO.Ports": "9.0.10" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "OpenTelemetry": "1.15.0" + } + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Sinks.OpenTelemetry": { + "type": "CentralTransitive", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", + "dependencies": { + "Google.Protobuf": "3.30.1", + "Grpc.Net.Client": "2.70.0", + "Serilog": "4.2.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" + } + } + } +} \ No newline at end of file diff --git a/src/Test/Werkr.Tests.Data/Unit/Collections/LoopingListTests.cs b/src/Test/Werkr.Tests.Data/Unit/Collections/LoopingListTests.cs new file mode 100644 index 0000000..1d28e12 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Collections/LoopingListTests.cs @@ -0,0 +1,121 @@ +using Werkr.Data.Collections; + +namespace Werkr.Tests.Data.Unit.Collections; + +[TestClass] +public class LoopingListTests { + + [TestMethod] + public void Constructor_PopulatesListWithCorrectItems( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items]; + Assert.AreEqual( 5, loopingList.Count ); + } + + [TestMethod] + public void Indexer_ReturnsCorrectItems( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items]; + Assert.AreEqual( 1, loopingList[0] ); + Assert.AreEqual( 2, loopingList[1] ); + Assert.AreEqual( 3, loopingList[2] ); + Assert.AreEqual( 4, loopingList[3] ); + Assert.AreEqual( 5, loopingList[4] ); + } + + [TestMethod] + public void Indexer_WrapsAroundCorrectly( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items]; + Assert.AreEqual( 1, loopingList[5] ); + Assert.AreEqual( 2, loopingList[6] ); + Assert.AreEqual( 3, loopingList[7] ); + Assert.AreEqual( 4, loopingList[8] ); + Assert.AreEqual( 5, loopingList[9] ); + } + + [TestMethod] + public void Indexer_NegativeIndex_ThrowsArgumentOutOfRangeException( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items]; + _ = Assert.ThrowsExactly( ( ) => _ = loopingList[-1] ); + } + + [TestMethod] + public void Add_IncreasesCountByOne( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items, 6]; + Assert.AreEqual( 6, loopingList.Count ); + Assert.AreEqual( 6, loopingList[5] ); + } + + [TestMethod] + public void Remove_DecreasesCountByOne( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items]; + _ = loopingList.Remove( 3 ); + Assert.AreEqual( 4, loopingList.Count ); + Assert.AreEqual( 4, loopingList[2] ); + } + + [TestMethod] + public void Contains_ReturnsTrueForExistingItems( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items]; + Assert.IsTrue( loopingList.Contains( 3 ) ); + } + + [TestMethod] + public void Contains_ReturnsFalseForNonExistingItems( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items]; + Assert.IsFalse( loopingList.Contains( 6 ) ); + } + + [TestMethod] + public void IndexOf_ReturnsCorrectIndexForExistingItems( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items]; + Assert.AreEqual( 2, loopingList.IndexOf( 3 ) ); + } + + [TestMethod] + public void IndexOf_ReturnsNegativeOneForNonExistingItems( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items]; + Assert.AreEqual( -1, loopingList.IndexOf( 6 ) ); + } + + [TestMethod] + public void Insert_InsertsItemAtCorrectIndex( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items]; + loopingList.Insert( 2, 6 ); + Assert.AreEqual( 6, loopingList.Count ); + Assert.AreEqual( 6, loopingList[2] ); + } + + [TestMethod] + public void Clear_RemovesAllItems( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items]; + loopingList.Clear( ); + Assert.AreEqual( 0, loopingList.Count ); + } + + [TestMethod] + public void Enumerator_IteratesOverAllItems( ) { + List items = [1, 2, 3, 4, 5]; + LoopingList loopingList = [.. items]; + int count = 0; + foreach (int item in loopingList) { + Assert.AreEqual( items[count % items.Count], item ); + count++; + if (count >= 10) { + break; // Two full cycles + } + } + Assert.AreEqual( 10, count ); + } + +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/AgentConnectionManagerTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/AgentConnectionManagerTests.cs new file mode 100644 index 0000000..156c45a --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/AgentConnectionManagerTests.cs @@ -0,0 +1,186 @@ +using Grpc.Core; + +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Common.Models; +using Werkr.Core.Communication; +using Werkr.Core.Cryptography; +using Werkr.Core.Cryptography.KeyInfo; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Tests.Data.Unit.Communication; + +[TestClass] +public class AgentConnectionManagerTests { + private SqliteConnection _connection = null!; + private SqliteWerkrDbContext _dbContext = null!; + private ServiceProvider _serviceProvider = null!; + private AgentConnectionManager _manager = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + + // Build a minimal DI container so AgentConnectionManager can resolve WerkrDbContext via IServiceScopeFactory + ServiceCollection services = new( ); + _ = services.AddDbContext( + b => b.UseSqlite( _connection ), + ServiceLifetime.Scoped ); + _ = services.AddDbContext( + b => b.UseSqlite( _connection ), + ServiceLifetime.Scoped ); + + _serviceProvider = services.BuildServiceProvider( ); + + _manager = new AgentConnectionManager( + _serviceProvider.GetRequiredService( ), + NullLogger.Instance ); + } + + [TestCleanup] + public void TestCleanup( ) { + _manager.Dispose( ); + _serviceProvider.Dispose( ); + _dbContext.Dispose( ); + _connection.Dispose( ); + } + + [TestMethod] + public async Task GetChannelAsync_ValidConnection_ReturnsChannel( ) { + RegisteredConnection conn = SeedServerConnection( ConnectionStatus.Connected ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + (Grpc.Net.Client.GrpcChannel channel, RegisteredConnection resolved) = + await _manager.GetChannelAsync( conn.Id, TestContext.CancellationToken ); + + Assert.IsNotNull( channel ); + Assert.AreEqual( conn.Id, resolved.Id ); + Assert.AreEqual( conn.RemoteUrl, resolved.RemoteUrl ); + } + + [TestMethod] + public async Task GetChannelAsync_RevokedConnection_Throws( ) { + RegisteredConnection conn = SeedServerConnection( ConnectionStatus.Revoked ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + _ = await Assert.ThrowsExactlyAsync( async ( ) => + await _manager.GetChannelAsync( conn.Id, TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task GetChannelAsync_NonExistentConnection_Throws( ) { + _ = await Assert.ThrowsExactlyAsync( async ( ) => + await _manager.GetChannelAsync( Guid.NewGuid( ), TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task GetChannelAsync_CachesChannels( ) { + RegisteredConnection conn = SeedServerConnection( ConnectionStatus.Connected ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + (Grpc.Net.Client.GrpcChannel channel1, _) = + await _manager.GetChannelAsync( conn.Id, TestContext.CancellationToken ); + + (Grpc.Net.Client.GrpcChannel channel2, _) = + await _manager.GetChannelAsync( conn.Id, TestContext.CancellationToken ); + + // Same channel instance should be returned (cached) + Assert.AreSame( channel1, channel2 ); + } + + [TestMethod] + public async Task RemoveChannel_DisposesAndRemoves( ) { + RegisteredConnection conn = SeedServerConnection( ConnectionStatus.Connected ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + (Grpc.Net.Client.GrpcChannel channel1, _) = + await _manager.GetChannelAsync( conn.Id, TestContext.CancellationToken ); + + _manager.RemoveChannel( conn.Id ); + + // Getting channel again should create a new one (different instance) + (Grpc.Net.Client.GrpcChannel channel2, _) = + await _manager.GetChannelAsync( conn.Id, TestContext.CancellationToken ); + + Assert.AreNotSame( channel1, channel2 ); + } + + [TestMethod] + public void CreateCallOptions_SetsMetadataCorrectly( ) { + Guid connId = Guid.NewGuid( ); + Guid callId = Guid.NewGuid( ); + RegisteredConnection conn = new( ) { + Id = connId, + ConnectionName = "Test", + RemoteUrl = "https://localhost:5001", + OutboundApiKey = "test-api-key", + InboundApiKeyHash = "hash", + SharedKey = new byte[32], + IsServer = true, + Status = ConnectionStatus.Connected, + }; + + CallOptions options = AgentConnectionManager.CreateCallOptions( conn, callId, TestContext.CancellationToken ); + + Assert.IsNotNull( options.Headers ); + Assert.AreEqual( $"Bearer test-api-key", options.Headers.GetValue( "authorization" ) ); + Assert.AreEqual( connId.ToString( ), options.Headers.GetValue( "x-werkr-connection-id" ) ); + Assert.AreEqual( callId.ToString( ), options.Headers.GetValue( "x-werkr-call-id" ) ); + } + + [TestMethod] + public void CreateCallOptions_SetsDeadline( ) { + RegisteredConnection conn = new( ) { + ConnectionName = "Test", + RemoteUrl = "https://localhost:5001", + OutboundApiKey = "key", + InboundApiKeyHash = "hash", + SharedKey = new byte[32], + IsServer = true, + Status = ConnectionStatus.Connected, + }; + + DateTime before = DateTime.UtcNow; + CallOptions options = AgentConnectionManager.CreateCallOptions( + conn, cancellationToken: TestContext.CancellationToken, timeout: TimeSpan.FromMinutes( 5 ) ); + DateTime after = DateTime.UtcNow; + + Assert.IsNotNull( options.Deadline ); + Assert.IsGreaterThanOrEqualTo( before.AddMinutes( 5 ), options.Deadline.Value ); + Assert.IsLessThanOrEqualTo( after.AddMinutes( 5 ).AddSeconds( 1 ), options.Deadline.Value ); + } + + private RegisteredConnection SeedServerConnection( ConnectionStatus status ) { + RSAKeyPair keys = EncryptionProvider.GenerateRSAKeyPair( ); + + RegisteredConnection conn = new( ) { + ConnectionName = "TestAgent", + RemoteUrl = "https://localhost:5001", + LocalPublicKey = keys.PublicKey, + LocalPrivateKey = keys.PrivateKey, + RemotePublicKey = keys.PublicKey, + OutboundApiKey = "outbound-key", + InboundApiKeyHash = "inbound-hash", + SharedKey = EncryptionProvider.GenerateRandomBytes( 32 ), + IsServer = true, + Status = status, + }; + + _ = _dbContext.RegisteredConnections.Add( conn ); + return conn; + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/CommandDispatcherTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/CommandDispatcherTests.cs new file mode 100644 index 0000000..86b98ef --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/CommandDispatcherTests.cs @@ -0,0 +1,151 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Common.Models; +using Werkr.Core.Communication; +using Werkr.Core.Cryptography; +using Werkr.Core.Cryptography.KeyInfo; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Tests.Data.Unit.Communication; + +[TestClass] +public class CommandDispatcherTests { + private SqliteConnection _connection = null!; + private SqliteWerkrDbContext _dbContext = null!; + private ServiceProvider _serviceProvider = null!; + private AgentConnectionManager _connectionManager = null!; + private CommandDispatcher _dispatcher = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + + ServiceCollection services = new( ); + _ = services.AddDbContext( + b => b.UseSqlite( _connection ), + ServiceLifetime.Scoped ); + _ = services.AddDbContext( + b => b.UseSqlite( _connection ), + ServiceLifetime.Scoped ); + + _serviceProvider = services.BuildServiceProvider( ); + + _connectionManager = new AgentConnectionManager( + _serviceProvider.GetRequiredService( ), + NullLogger.Instance ); + + _dispatcher = new CommandDispatcher( + _connectionManager, + NullLogger.Instance ); + } + + [TestCleanup] + public void TestCleanup( ) { + _connectionManager.Dispose( ); + _serviceProvider.Dispose( ); + _dbContext.Dispose( ); + _connection.Dispose( ); + } + + [TestMethod] + public async Task ExecuteCommandAsync_UnsupportedOperator_ReturnsSingleError( ) { + RegisteredConnection conn = SeedServerConnection( ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + List outputs = await ToListAsync( + _dispatcher.ExecuteCommandAsync( + conn.Id, + OperatorType.Action, + "noop", + TestContext.CancellationToken ), + TestContext.CancellationToken ); + + Assert.HasCount( 1, outputs ); + Assert.AreEqual( "Error", outputs[0].LogLevel ); + Assert.Contains( "Unsupported operator type", outputs[0].Message ); + } + + [TestMethod] + public async Task ExecuteScriptAsync_UnsupportedOperatorWithoutArgs_ReturnsSingleError( ) { + RegisteredConnection conn = SeedServerConnection( ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + List outputs = await ToListAsync( + _dispatcher.ExecuteScriptAsync( + conn.Id, + OperatorType.Action, + "script.ps1", + args: null, + TestContext.CancellationToken ), + TestContext.CancellationToken ); + + Assert.HasCount( 1, outputs ); + Assert.AreEqual( "Error", outputs[0].LogLevel ); + Assert.Contains( "Unsupported operator type", outputs[0].Message ); + } + + [TestMethod] + public async Task ExecuteScriptAsync_UnsupportedOperatorWithArgs_ReturnsSingleError( ) { + RegisteredConnection conn = SeedServerConnection( ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + List outputs = await ToListAsync( + _dispatcher.ExecuteScriptAsync( + conn.Id, + OperatorType.Action, + "script.ps1", + ["arg1", "arg2"], + TestContext.CancellationToken ), + TestContext.CancellationToken ); + + Assert.HasCount( 1, outputs ); + Assert.AreEqual( "Error", outputs[0].LogLevel ); + Assert.Contains( "Unsupported operator type", outputs[0].Message ); + } + + private RegisteredConnection SeedServerConnection( ) { + RSAKeyPair keys = EncryptionProvider.GenerateRSAKeyPair( ); + + RegisteredConnection conn = new( ) { + ConnectionName = "DispatcherAgent", + RemoteUrl = "https://localhost:54321", + LocalPublicKey = keys.PublicKey, + LocalPrivateKey = keys.PrivateKey, + RemotePublicKey = keys.PublicKey, + OutboundApiKey = "outbound-key", + InboundApiKeyHash = "inbound-hash", + SharedKey = EncryptionProvider.GenerateRandomBytes( 32 ), + IsServer = true, + Status = ConnectionStatus.Connected, + }; + + _ = _dbContext.RegisteredConnections.Add( conn ); + return conn; + } + + private static async Task> ToListAsync( + IAsyncEnumerable sequence, + CancellationToken cancellationToken ) { + + List outputs = []; + await foreach (OperatorOutput output in sequence.WithCancellation( cancellationToken )) { + outputs.Add( output ); + } + + return outputs; + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/KeyRotationTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/KeyRotationTests.cs new file mode 100644 index 0000000..c915ceb --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/KeyRotationTests.cs @@ -0,0 +1,223 @@ +using System.Security.Cryptography; + +using Google.Protobuf; + +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Cryptography; +using Werkr.Core.Cryptography.KeyInfo; + +namespace Werkr.Tests.Data.Unit.Communication; + +/// +/// Tests key rotation flows: round-trip RSA encryption of new keys, +/// grace period handling via key rotation overload, +/// and key ID matching logic. +/// +[TestClass] +public class KeyRotationTests { + [TestMethod] + public void RotationRoundTrip_RsaEncryptNewKey_AgentDecrypts( ) { + // Simulate: API generates new key, RSA-encrypts with Agent's public key + RSAKeyPair agentKeys = EncryptionProvider.GenerateRSAKeyPair( ); + byte[] newKey = EncryptionProvider.GenerateRandomBytes( 32 ); + + using RSA rsaEncrypt = RSA.Create( ); + rsaEncrypt.ImportParameters( agentKeys.PublicKey ); + byte[] rsaEncryptedNewKey = rsaEncrypt.Encrypt( newKey, RSAEncryptionPadding.OaepSHA256 ); + + // Simulate: Agent decrypts with its private key + using RSA rsaDecrypt = RSA.Create( ); + rsaDecrypt.ImportParameters( agentKeys.PrivateKey ); + byte[] decryptedKey = rsaDecrypt.Decrypt( rsaEncryptedNewKey, RSAEncryptionPadding.OaepSHA256 ); + + CollectionAssert.AreEqual( newKey, decryptedKey ); + } + + [TestMethod] + public void RotationRoundTrip_WrongPrivateKey_Throws( ) { + RSAKeyPair agentKeys = EncryptionProvider.GenerateRSAKeyPair( ); + RSAKeyPair wrongKeys = EncryptionProvider.GenerateRSAKeyPair( ); + byte[] newKey = EncryptionProvider.GenerateRandomBytes( 32 ); + + using RSA rsaEncrypt = RSA.Create( ); + rsaEncrypt.ImportParameters( agentKeys.PublicKey ); + byte[] rsaEncryptedNewKey = rsaEncrypt.Encrypt( newKey, RSAEncryptionPadding.OaepSHA256 ); + + // Attempt to decrypt with wrong private key + using RSA rsaDecrypt = RSA.Create( ); + rsaDecrypt.ImportParameters( wrongKeys.PrivateKey ); + + // macOS throws a platform-specific CryptographicException subclass, + // so we catch the base type instead of using ThrowsExactly. + bool threw = false; + try { + _ = rsaDecrypt.Decrypt( rsaEncryptedNewKey, RSAEncryptionPadding.OaepSHA256 ); + } catch (CryptographicException) { + threw = true; + } + Assert.IsTrue( threw, "Expected CryptographicException when decrypting with wrong key." ); + } + + [TestMethod] + public void GracePeriod_OldKeyStillDecryptsDuringTransition( ) { + byte[] oldKey = EncryptionProvider.GenerateRandomBytes( 32 ); + byte[] newKey = EncryptionProvider.GenerateRandomBytes( 32 ); + string oldKeyId = "key-1"; + string newKeyId = "key-2"; + + // Message encrypted with old key (in-flight during rotation) + HeartbeatRequest original = new( ) { StatusMessage = "in-flight message" }; + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, oldKey, oldKeyId ); + + // Receiver has rotated to new key but still holds old key as previous + HeartbeatRequest decrypted = PayloadEncryptor.DecryptFromEnvelope( + envelope, newKey, newKeyId, oldKey, oldKeyId ); + + Assert.AreEqual( original.StatusMessage, decrypted.StatusMessage ); + } + + [TestMethod] + public void GracePeriod_NewKeyDecryptsPostRotation( ) { + byte[] oldKey = EncryptionProvider.GenerateRandomBytes( 32 ); + byte[] newKey = EncryptionProvider.GenerateRandomBytes( 32 ); + string oldKeyId = "key-1"; + string newKeyId = "key-2"; + + // Message encrypted with new key (post-rotation) + HeartbeatRequest original = new( ) { StatusMessage = "post-rotation message" }; + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, newKey, newKeyId ); + + // Receiver has rotated and holds old key as previous + HeartbeatRequest decrypted = PayloadEncryptor.DecryptFromEnvelope( + envelope, newKey, newKeyId, oldKey, oldKeyId ); + + Assert.AreEqual( original.StatusMessage, decrypted.StatusMessage ); + } + + [TestMethod] + public void GracePeriod_NoPreviousKey_DecryptsWithCurrentOnly( ) { + byte[] currentKey = EncryptionProvider.GenerateRandomBytes( 32 ); + string currentKeyId = "key-1"; + + HeartbeatRequest original = new( ) { StatusMessage = "no previous key" }; + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, currentKey, currentKeyId ); + + HeartbeatRequest decrypted = PayloadEncryptor.DecryptFromEnvelope( + envelope, currentKey, currentKeyId, null, null ); + + Assert.AreEqual( original.StatusMessage, decrypted.StatusMessage ); + } + + [TestMethod] + public void GracePeriod_UnknownKeyId_FallsBackToCurrentKey( ) { + byte[] currentKey = EncryptionProvider.GenerateRandomBytes( 32 ); + string currentKeyId = "key-2"; + string unknownKeyId = "key-unknown"; + + // Encrypt with current key but use an unknown key ID + HeartbeatRequest original = new( ) { StatusMessage = "unknown key id" }; + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, currentKey, unknownKeyId ); + + // Should fall back to current key as last resort + HeartbeatRequest decrypted = PayloadEncryptor.DecryptFromEnvelope( + envelope, currentKey, currentKeyId, null, null ); + + Assert.AreEqual( original.StatusMessage, decrypted.StatusMessage ); + } + + [TestMethod] + public void GracePeriod_ExpiredPreviousKey_FailsAfterGracePeriod( ) { + byte[] oldKey = EncryptionProvider.GenerateRandomBytes( 32 ); + byte[] newKey = EncryptionProvider.GenerateRandomBytes( 32 ); + string newKeyId = "key-2"; + + // Message encrypted with old key — but the previous key slot has been cleared + HeartbeatRequest original = new( ) { StatusMessage = "expired" }; + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, oldKey, "key-1" ); + + // Receiver no longer has the old key (grace period expired) + _ = Assert.ThrowsExactly( ( ) => + PayloadEncryptor.DecryptFromEnvelope( + envelope, newKey, newKeyId, null, null ) ); + } + + [TestMethod] + public void RotationProtocol_EnvelopeContainsRotationRequest( ) { + // Verify the full envelope round-trip for a RotateSharedKeyRequest + byte[] currentKey = EncryptionProvider.GenerateRandomBytes( 32 ); + string currentKeyId = "key-1"; + + RSAKeyPair agentKeys = EncryptionProvider.GenerateRSAKeyPair( ); + byte[] newKey = EncryptionProvider.GenerateRandomBytes( 32 ); + + using RSA rsa = RSA.Create( ); + rsa.ImportParameters( agentKeys.PublicKey ); + byte[] rsaEncryptedNewKey = rsa.Encrypt( newKey, RSAEncryptionPadding.OaepSHA256 ); + + RotateSharedKeyRequest rotationRequest = new( ) { + RsaEncryptedNewKey = ByteString.CopyFrom( rsaEncryptedNewKey ), + NewKeyId = "key-2", + }; + + // Encrypt with current SharedKey + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( + rotationRequest, currentKey, currentKeyId ); + + // Decrypt with current SharedKey + RotateSharedKeyRequest decrypted = PayloadEncryptor.DecryptFromEnvelope( + envelope, currentKey ); + + Assert.AreEqual( "key-2", decrypted.NewKeyId ); + + // Agent side: decrypt the RSA payload + using RSA agentRsa = RSA.Create( ); + agentRsa.ImportParameters( agentKeys.PrivateKey ); + byte[] recoveredKey = agentRsa.Decrypt( + decrypted.RsaEncryptedNewKey.ToByteArray( ), RSAEncryptionPadding.OaepSHA256 ); + + CollectionAssert.AreEqual( newKey, recoveredKey ); + } + + [TestMethod] + public void RotationResponse_EncryptedWithNewKey_Decrypts( ) { + byte[] newKey = EncryptionProvider.GenerateRandomBytes( 32 ); + string newKeyId = "key-2"; + + // Agent encrypts response with the newly activated key + RotateSharedKeyResponse response = new( ) { + Success = true, + ActiveKeyId = newKeyId, + }; + + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( + response, newKey, newKeyId ); + + // API decrypts with the new key it generated + RotateSharedKeyResponse decrypted = PayloadEncryptor.DecryptFromEnvelope( + envelope, newKey ); + + Assert.IsTrue( decrypted.Success ); + Assert.AreEqual( newKeyId, decrypted.ActiveKeyId ); + } + + [TestMethod] + public void KeyIdPreserved_AcrossRotationOverload( ) { + byte[] currentKey = EncryptionProvider.GenerateRandomBytes( 32 ); + byte[] previousKey = EncryptionProvider.GenerateRandomBytes( 32 ); + string currentKeyId = "key-2"; + string previousKeyId = "key-1"; + + // Encrypt with previous key, verify key ID is preserved in envelope + HeartbeatRequest original = new( ) { StatusMessage = "check key id" }; + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, previousKey, previousKeyId ); + + Assert.AreEqual( previousKeyId, envelope.KeyId ); + + // Rotation overload selects the right key based on key ID + HeartbeatRequest decrypted = PayloadEncryptor.DecryptFromEnvelope( + envelope, currentKey, currentKeyId, previousKey, previousKeyId ); + + Assert.AreEqual( original.StatusMessage, decrypted.StatusMessage ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/NullEncryptionTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/NullEncryptionTests.cs new file mode 100644 index 0000000..8c9d994 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/NullEncryptionTests.cs @@ -0,0 +1,53 @@ +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Cryptography; + +namespace Werkr.Tests.Data.Unit.Communication; + +/// +/// Tests that null SharedKey causes hard failure everywhere encryption is required. +/// Verifies Decision B2: null-encryption fallback is removed. +/// +[TestClass] +public class NullEncryptionTests { + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + public void EncryptToEnvelope_NullKey_ThrowsArgumentNullException( ) { + HeartbeatRequest message = new( ) { StatusMessage = "test" }; + + _ = Assert.ThrowsExactly( ( ) => + PayloadEncryptor.EncryptToEnvelope( message, null!, "key-1" ) ); + } + + [TestMethod] + public void DecryptFromEnvelope_NullKey_ThrowsArgumentNullException( ) { + byte[] validKey = EncryptionProvider.GenerateRandomBytes( 32 ); + HeartbeatRequest message = new( ) { StatusMessage = "test" }; + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( message, validKey, "key-1" ); + + _ = Assert.ThrowsExactly( ( ) => + PayloadEncryptor.DecryptFromEnvelope( envelope, null! ) ); + } + + [TestMethod] + public void DecryptFromEnvelope_KeyRotation_NullCurrentKey_ThrowsArgumentNullException( ) { + byte[] validKey = EncryptionProvider.GenerateRandomBytes( 32 ); + HeartbeatRequest message = new( ) { StatusMessage = "test" }; + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( message, validKey, "key-1" ); + + _ = Assert.ThrowsExactly( ( ) => + PayloadEncryptor.DecryptFromEnvelope( + envelope, null!, "key-2", validKey, "key-1" ) ); + } + + [TestMethod] + public void GrpcOutputReader_NullKey_ThrowsArgumentNullException( ) { + _ = Assert.ThrowsExactly( ( ) => { + // ReadAsync is an async iterator, so enumerate to trigger the guard + IAsyncEnumerable reader = GrpcOutputReader.ReadAsync( null!, null!, TestContext.CancellationToken ); + IAsyncEnumerator enumerator = reader.GetAsyncEnumerator( TestContext.CancellationToken ); + _ = enumerator.MoveNextAsync( ).AsTask( ).GetAwaiter( ).GetResult( ); + } ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/PayloadEncryptorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/PayloadEncryptorTests.cs new file mode 100644 index 0000000..9411b9e --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/PayloadEncryptorTests.cs @@ -0,0 +1,171 @@ +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Cryptography; + +namespace Werkr.Tests.Data.Unit.Communication; + +[TestClass] +public class PayloadEncryptorTests { + private byte[] _sharedKey = null!; + private const string TestKeyId = "test-key-1"; + + [TestInitialize] + public void TestInit( ) { + _sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); + } + + [TestMethod] + public void EncryptDecryptEnvelope_RoundTrip( ) { + HeartbeatRequest original = new( ) { StatusMessage = "Hello, encrypted world!" }; + + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, _sharedKey, TestKeyId ); + HeartbeatRequest decrypted = PayloadEncryptor.DecryptFromEnvelope( envelope, _sharedKey ); + + Assert.AreEqual( original.StatusMessage, decrypted.StatusMessage ); + } + + [TestMethod] + public void EncryptDecryptEnvelope_EmptyMessage_RoundTrip( ) { + HeartbeatRequest original = new( ); + + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, _sharedKey, TestKeyId ); + HeartbeatRequest decrypted = PayloadEncryptor.DecryptFromEnvelope( envelope, _sharedKey ); + + Assert.AreEqual( original.StatusMessage, decrypted.StatusMessage ); + } + + [TestMethod] + public void EncryptDecryptEnvelope_LargePayload_RoundTrip( ) { + HeartbeatRequest original = new( ) { StatusMessage = new string( 'A', 100_000 ) }; + + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, _sharedKey, TestKeyId ); + HeartbeatRequest decrypted = PayloadEncryptor.DecryptFromEnvelope( envelope, _sharedKey ); + + Assert.AreEqual( original.StatusMessage, decrypted.StatusMessage ); + } + + [TestMethod] + public void EncryptToEnvelope_SetsKeyId( ) { + HeartbeatRequest original = new( ) { StatusMessage = "test payload" }; + + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, _sharedKey, TestKeyId ); + + Assert.AreEqual( TestKeyId, envelope.KeyId ); + Assert.IsFalse( envelope.Ciphertext.IsEmpty ); + Assert.IsFalse( envelope.Iv.IsEmpty ); + Assert.IsFalse( envelope.AuthTag.IsEmpty ); + } + + [TestMethod] + public void EncryptToEnvelope_DifferentIvEachCall( ) { + HeartbeatRequest original = new( ) { StatusMessage = "same plaintext" }; + + EncryptedEnvelope envelope1 = PayloadEncryptor.EncryptToEnvelope( original, _sharedKey, TestKeyId ); + EncryptedEnvelope envelope2 = PayloadEncryptor.EncryptToEnvelope( original, _sharedKey, TestKeyId ); + + Assert.AreNotEqual( envelope1.Iv, envelope2.Iv ); + } + + [TestMethod] + public void DecryptFromEnvelope_WrongKey_Throws( ) { + HeartbeatRequest original = new( ) { StatusMessage = "secret data" }; + byte[] wrongKey = EncryptionProvider.GenerateRandomBytes( 32 ); + + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, _sharedKey, TestKeyId ); + + _ = Assert.ThrowsExactly( ( ) => + PayloadEncryptor.DecryptFromEnvelope( envelope, wrongKey ) ); + } + + [TestMethod] + public void DecryptFromEnvelope_TamperedCiphertext_Throws( ) { + HeartbeatRequest original = new( ) { StatusMessage = "secret data" }; + + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, _sharedKey, TestKeyId ); + + byte[] cipherBytes = envelope.Ciphertext.ToByteArray( ); + cipherBytes[^1] ^= 0xFF; + + EncryptedEnvelope tampered = new( ) { + Ciphertext = Google.Protobuf.ByteString.CopyFrom( cipherBytes ), + Iv = envelope.Iv, + AuthTag = envelope.AuthTag, + KeyId = envelope.KeyId, + }; + + _ = Assert.ThrowsExactly( ( ) => + PayloadEncryptor.DecryptFromEnvelope( tampered, _sharedKey ) ); + } + + [TestMethod] + public void DecryptFromEnvelope_TamperedIv_Throws( ) { + HeartbeatRequest original = new( ) { StatusMessage = "secret data" }; + + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, _sharedKey, TestKeyId ); + + byte[] ivBytes = envelope.Iv.ToByteArray( ); + ivBytes[0] ^= 0xFF; + + EncryptedEnvelope tampered = new( ) { + Ciphertext = envelope.Ciphertext, + Iv = Google.Protobuf.ByteString.CopyFrom( ivBytes ), + AuthTag = envelope.AuthTag, + KeyId = envelope.KeyId, + }; + + _ = Assert.ThrowsExactly( ( ) => + PayloadEncryptor.DecryptFromEnvelope( tampered, _sharedKey ) ); + } + + [TestMethod] + public void DecryptFromEnvelope_TamperedAuthTag_Throws( ) { + HeartbeatRequest original = new( ) { StatusMessage = "secret data" }; + + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, _sharedKey, TestKeyId ); + + byte[] tagBytes = envelope.AuthTag.ToByteArray( ); + tagBytes[0] ^= 0xFF; + + EncryptedEnvelope tampered = new( ) { + Ciphertext = envelope.Ciphertext, + Iv = envelope.Iv, + AuthTag = Google.Protobuf.ByteString.CopyFrom( tagBytes ), + KeyId = envelope.KeyId, + }; + + _ = Assert.ThrowsExactly( ( ) => + PayloadEncryptor.DecryptFromEnvelope( tampered, _sharedKey ) ); + } + + [TestMethod] + public void DecryptFromEnvelope_KeyRotation_DecryptsWithCurrentKey( ) { + byte[] currentKey = EncryptionProvider.GenerateRandomBytes( 32 ); + byte[] previousKey = EncryptionProvider.GenerateRandomBytes( 32 ); + string currentKeyId = "key-2"; + string previousKeyId = "key-1"; + + HeartbeatRequest original = new( ) { StatusMessage = "rotated" }; + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, currentKey, currentKeyId ); + + HeartbeatRequest decrypted = PayloadEncryptor.DecryptFromEnvelope( + envelope, currentKey, currentKeyId, previousKey, previousKeyId ); + + Assert.AreEqual( original.StatusMessage, decrypted.StatusMessage ); + } + + [TestMethod] + public void DecryptFromEnvelope_KeyRotation_DecryptsWithPreviousKey( ) { + byte[] currentKey = EncryptionProvider.GenerateRandomBytes( 32 ); + byte[] previousKey = EncryptionProvider.GenerateRandomBytes( 32 ); + string currentKeyId = "key-2"; + string previousKeyId = "key-1"; + + HeartbeatRequest original = new( ) { StatusMessage = "in-flight" }; + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, previousKey, previousKeyId ); + + HeartbeatRequest decrypted = PayloadEncryptor.DecryptFromEnvelope( + envelope, currentKey, currentKeyId, previousKey, previousKeyId ); + + Assert.AreEqual( original.StatusMessage, decrypted.StatusMessage ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Configuration/ConfigurationExtensionsTests.cs b/src/Test/Werkr.Tests.Data/Unit/Configuration/ConfigurationExtensionsTests.cs new file mode 100644 index 0000000..5a9c047 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Configuration/ConfigurationExtensionsTests.cs @@ -0,0 +1,117 @@ +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Werkr.Common.Extensions; + +namespace Werkr.Tests.Data.Unit.Configuration; + +/// +/// Tests for . +/// +[TestClass] +public sealed class ConfigurationExtensionsTests { + /// + /// Verifies that + /// loads values from a JSON file specified by the WERKR_CONFIG_PATH environment variable. + /// + [TestMethod] + public void AddWerkrConfigPath_WithValidJsonFile_LoadsValues( ) { + // Arrange + string tempFile = Path.GetTempFileName(); + try { + File.WriteAllText( tempFile, """{"TestSection": {"Key1": "Value1"}}""" ); + Environment.SetEnvironmentVariable( "WERKR_CONFIG_PATH", tempFile ); + + IConfigurationBuilder builder = new ConfigurationBuilder(); + + // Act + _ = builder.AddWerkrConfigPath( ); + IConfigurationRoot config = builder.Build(); + + // Assert + Assert.AreEqual( "Value1", config["TestSection:Key1"] ); + } finally { + Environment.SetEnvironmentVariable( "WERKR_CONFIG_PATH", null ); + File.Delete( tempFile ); + } + } + + /// + /// Verifies that + /// gracefully handles the case where WERKR_CONFIG_PATH is not set. + /// + [TestMethod] + public void AddWerkrConfigPath_WithNoEnvVar_ReturnsEmptyConfig( ) { + // Arrange + Environment.SetEnvironmentVariable( "WERKR_CONFIG_PATH", null ); + IConfigurationBuilder builder = new ConfigurationBuilder(); + + // Act + _ = builder.AddWerkrConfigPath( ); + IConfigurationRoot config = builder.Build(); + + // Assert — should not throw and should produce a valid (empty) config + Assert.IsNotNull( config ); + Assert.IsNull( config["NonExistent:Key"] ); + } + + /// + /// Verifies that + /// gracefully handles a WERKR_CONFIG_PATH pointing to a nonexistent file + /// (the file is added as optional). + /// + [TestMethod] + public void AddWerkrConfigPath_WithMissingFile_DoesNotThrow( ) { + // Arrange + string missingPath = Path.Combine( Path.GetTempPath(), $"werkr-test-missing-{Guid.NewGuid()}.json" ); + Environment.SetEnvironmentVariable( "WERKR_CONFIG_PATH", missingPath ); + IConfigurationBuilder builder = new ConfigurationBuilder(); + + try { + // Act + _ = builder.AddWerkrConfigPath( ); + IConfigurationRoot config = builder.Build(); + + // Assert — should not throw and should produce a valid (empty) config + Assert.IsNotNull( config ); + } finally { + Environment.SetEnvironmentVariable( "WERKR_CONFIG_PATH", null ); + } + } + + /// + /// Verifies that + /// returns the builder for fluent chaining. + /// + [TestMethod] + public void AddWerkrConfigPath_ReturnsSameBuilder_ForChaining( ) { + // Arrange + Environment.SetEnvironmentVariable( "WERKR_CONFIG_PATH", null ); + IConfigurationBuilder builder = new ConfigurationBuilder(); + + // Act + IConfigurationBuilder result = builder.AddWerkrConfigPath(); + + // Assert + Assert.AreSame( builder, result ); + } + + /// + /// Verifies that the assembly InformationalVersion attribute is present on the + /// entry assembly, validating the GitVersion integration produces a version string. + /// + [TestMethod] + public void AssemblyVersion_InformationalVersion_IsPresent( ) { + // Arrange — use the test assembly itself (it inherits Directory.Build.props versioning) + Assembly assembly = typeof( ConfigurationExtensionsTests ).Assembly; + + // Act + AssemblyInformationalVersionAttribute? attr = assembly + .GetCustomAttribute(); + + // Assert — the attribute should always be present (defaults to 1.0.0 without GitVersion) + Assert.IsNotNull( attr, "AssemblyInformationalVersionAttribute should be present." ); + Assert.IsFalse( + string.IsNullOrWhiteSpace( attr.InformationalVersion ), + "InformationalVersion should not be empty." ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Cryptography/EncryptionProviderTests.cs b/src/Test/Werkr.Tests.Data/Unit/Cryptography/EncryptionProviderTests.cs new file mode 100644 index 0000000..85848c9 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Cryptography/EncryptionProviderTests.cs @@ -0,0 +1,179 @@ +using System.Security.Cryptography; +using System.Text; + +using Werkr.Core.Cryptography; +using Werkr.Core.Cryptography.KeyInfo; + +namespace Werkr.Tests.Data.Unit.Cryptography; + +[TestClass] +public class EncryptionProviderTests { + // -- RSA Key Generation -- + + [TestMethod] + public void GenerateRSAKeyPair_Default4096_ProducesValidKeyPair( ) { + RSAKeyPair keyPair = EncryptionProvider.GenerateRSAKeyPair( ); + + Assert.AreEqual( 4096, keyPair.KeySize ); + Assert.IsNotNull( keyPair.PublicKey.Modulus ); + Assert.HasCount( 512, keyPair.PublicKey.Modulus ); // 4096 / 8 + Assert.IsNotNull( keyPair.PrivateKey.D ); + } + + [TestMethod] + public void GenerateRSAKeyPair_Custom2048_ProducesCorrectSize( ) { + RSAKeyPair keyPair = EncryptionProvider.GenerateRSAKeyPair( 2048 ); + + Assert.AreEqual( 2048, keyPair.KeySize ); + Assert.IsNotNull( keyPair.PublicKey.Modulus ); + Assert.HasCount( 256, keyPair.PublicKey.Modulus ); // 2048 / 8 + } + + [TestMethod] + public void GenerateRSAKeyPair_KeySizeTooSmall_Throws( ) { + _ = Assert.ThrowsExactly( + ( ) => EncryptionProvider.GenerateRSAKeyPair( 1024 ) ); + } + + [TestMethod] + public void GenerateRSAKeyPair_KeySizeNotDivisibleBy8_Throws( ) { + _ = Assert.ThrowsExactly( + ( ) => EncryptionProvider.GenerateRSAKeyPair( 2049 ) ); + } + + // -- RSA Encrypt / Decrypt -- + + [TestMethod] + public void RSAEncryptDecrypt_RoundTrip_ReturnsOriginalData( ) { + RSAKeyPair keyPair = EncryptionProvider.GenerateRSAKeyPair( 2048 ); + byte[] plaintext = Encoding.UTF8.GetBytes( "Hello, Werkr!" ); + + byte[] ciphertext = EncryptionProvider.RSAEncrypt( plaintext, keyPair.PublicKey ); + byte[] decrypted = EncryptionProvider.RSADecrypt( ciphertext, keyPair.PrivateKey ); + + CollectionAssert.AreEqual( plaintext, decrypted ); + } + + [TestMethod] + public void RSADecrypt_WrongKey_ThrowsWerkrCryptoException( ) { + RSAKeyPair keyPair1 = EncryptionProvider.GenerateRSAKeyPair( 2048 ); + RSAKeyPair keyPair2 = EncryptionProvider.GenerateRSAKeyPair( 2048 ); + byte[] plaintext = Encoding.UTF8.GetBytes( "Secret" ); + byte[] ciphertext = EncryptionProvider.RSAEncrypt( plaintext, keyPair1.PublicKey ); + + _ = Assert.ThrowsExactly( + ( ) => EncryptionProvider.RSADecrypt( ciphertext, keyPair2.PrivateKey ) ); + } + + // -- AES-256-GCM -- + + [TestMethod] + public void AesGcmEncryptDecrypt_RoundTrip_ReturnsOriginalData( ) { + byte[] key = EncryptionProvider.GenerateRandomBytes( EncryptionProvider.AesGcmKeySize ); + byte[] plaintext = Encoding.UTF8.GetBytes( "AES-GCM test data" ); + + byte[] ciphertext = EncryptionProvider.AesGcmEncrypt( plaintext, key, out byte[] nonce, out byte[] tag ); + byte[] decrypted = EncryptionProvider.AesGcmDecrypt( ciphertext, key, nonce, tag ); + + CollectionAssert.AreEqual( plaintext, decrypted ); + } + + [TestMethod] + public void AesGcmDecrypt_WrongKey_ThrowsWerkrCryptoException( ) { + byte[] key1 = EncryptionProvider.GenerateRandomBytes( EncryptionProvider.AesGcmKeySize ); + byte[] key2 = EncryptionProvider.GenerateRandomBytes( EncryptionProvider.AesGcmKeySize ); + byte[] plaintext = Encoding.UTF8.GetBytes( "Secret" ); + + byte[] ciphertext = EncryptionProvider.AesGcmEncrypt( plaintext, key1, out byte[] nonce, out byte[] tag ); + + _ = Assert.ThrowsExactly( + ( ) => EncryptionProvider.AesGcmDecrypt( ciphertext, key2, nonce, tag ) ); + } + + // -- Password-based AES-GCM -- + + [TestMethod] + public void AesGcmPasswordEncryptDecrypt_RoundTrip_ReturnsOriginalData( ) { + byte[] plaintext = Encoding.UTF8.GetBytes( "Password-encrypted data" ); + string password = "StrongPassword123!"; + + byte[] encrypted = EncryptionProvider.AesGcmPasswordEncrypt( plaintext, password ); + byte[] decrypted = EncryptionProvider.AesGcmPasswordDecrypt( encrypted, password ); + + CollectionAssert.AreEqual( plaintext, decrypted ); + } + + [TestMethod] + public void AesGcmPasswordDecrypt_WrongPassword_ThrowsWerkrCryptoException( ) { + byte[] plaintext = Encoding.UTF8.GetBytes( "Secret" ); + byte[] encrypted = EncryptionProvider.AesGcmPasswordEncrypt( plaintext, "CorrectPassword" ); + + _ = Assert.ThrowsExactly( + ( ) => EncryptionProvider.AesGcmPasswordDecrypt( encrypted, "WrongPassword" ) ); + } + + // -- Sign / Verify -- + + [TestMethod] + public void SignVerify_ValidSignature_ReturnsTrue( ) { + RSAKeyPair keyPair = EncryptionProvider.GenerateRSAKeyPair( 2048 ); + byte[] data = Encoding.UTF8.GetBytes( "Sign this data" ); + + byte[] signature = EncryptionProvider.Sign( data, keyPair.PrivateKey ); + bool isValid = EncryptionProvider.Verify( data, signature, keyPair.PublicKey ); + + Assert.IsTrue( isValid ); + } + + [TestMethod] + public void Verify_WrongKey_ReturnsFalse( ) { + RSAKeyPair keyPair1 = EncryptionProvider.GenerateRSAKeyPair( 2048 ); + RSAKeyPair keyPair2 = EncryptionProvider.GenerateRSAKeyPair( 2048 ); + byte[] data = Encoding.UTF8.GetBytes( "Sign this data" ); + byte[] signature = EncryptionProvider.Sign( data, keyPair1.PrivateKey ); + + bool isValid = EncryptionProvider.Verify( data, signature, keyPair2.PublicKey ); + + Assert.IsFalse( isValid ); + } + + [TestMethod] + public void Verify_TamperedData_ReturnsFalse( ) { + RSAKeyPair keyPair = EncryptionProvider.GenerateRSAKeyPair( 2048 ); + byte[] data = Encoding.UTF8.GetBytes( "Sign this data" ); + byte[] signature = EncryptionProvider.Sign( data, keyPair.PrivateKey ); + + byte[] tampered = (byte[]) data.Clone( ); + tampered[0] ^= 0xFF; + + bool isValid = EncryptionProvider.Verify( tampered, signature, keyPair.PublicKey ); + + Assert.IsFalse( isValid ); + } + + // -- Serialize / Deserialize Public Key -- + + [TestMethod] + public void SerializeDeserializePublicKey_RoundTrip_PreservesKey( ) { + RSAKeyPair keyPair = EncryptionProvider.GenerateRSAKeyPair( 2048 ); + + byte[] serialized = EncryptionProvider.SerializePublicKey( keyPair.PublicKey ); + RSAParameters deserialized = EncryptionProvider.DeserializePublicKey( serialized ); + + CollectionAssert.AreEqual( keyPair.PublicKey.Modulus, deserialized.Modulus ); + CollectionAssert.AreEqual( keyPair.PublicKey.Exponent, deserialized.Exponent ); + } + + // -- Hashing -- + + [TestMethod] + public void HashSHA512String_DeterministicOutput( ) { + string input = "test input"; + + string hash1 = EncryptionProvider.HashSHA512String( input ); + string hash2 = EncryptionProvider.HashSHA512String( input ); + + Assert.AreEqual( hash1, hash2 ); + Assert.HasCount( 128, hash1 ); // SHA-512 = 64 bytes = 128 hex chars + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Cryptography/HybridEncryptionTests.cs b/src/Test/Werkr.Tests.Data/Unit/Cryptography/HybridEncryptionTests.cs new file mode 100644 index 0000000..fec7353 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Cryptography/HybridEncryptionTests.cs @@ -0,0 +1,66 @@ +using System.Text; + +using Werkr.Core.Cryptography; +using Werkr.Core.Cryptography.KeyInfo; + +namespace Werkr.Tests.Data.Unit.Cryptography; + +[TestClass] +public class HybridEncryptionTests { + private static RSAKeyPair s_keyPair = null!; + + [ClassInitialize] + public static void ClassInit( TestContext context ) { + // Generate once — RSA-4096 is required for hybrid operations (HybridDecrypt hardcodes 512-byte RSA block). + s_keyPair = EncryptionProvider.GenerateRSAKeyPair( ); + } + + [TestMethod] + public void HybridEncryptDecrypt_RoundTrip_ReturnsOriginalData( ) { + byte[] plaintext = Encoding.UTF8.GetBytes( "Hybrid encryption round-trip test data" ); + + byte[] encrypted = EncryptionProvider.HybridEncrypt( plaintext, s_keyPair.PublicKey ); + byte[] decrypted = EncryptionProvider.HybridDecrypt( encrypted, s_keyPair.PrivateKey ); + + CollectionAssert.AreEqual( plaintext, decrypted ); + } + + [TestMethod] + public void HybridEncrypt_SmallPayload_ProducesCorrectEnvelopeSize( ) { + byte[] plaintext = [1, 2, 3]; + + byte[] encrypted = EncryptionProvider.HybridEncrypt( plaintext, s_keyPair.PublicKey ); + + // Envelope: rsaEncryptedKey (512) + nonce (12) + tag (16) + ciphertext (same length as plaintext) + int expectedSize = EncryptionProvider.RsaEncryptedBlockSize + + EncryptionProvider.AesGcmNonceSize + + EncryptionProvider.AesGcmTagSize + + plaintext.Length; + Assert.HasCount( expectedSize, encrypted ); + } + + [TestMethod] + public void HybridDecrypt_WrongKey_ThrowsWerkrCryptoException( ) { + RSAKeyPair wrongKeyPair = EncryptionProvider.GenerateRSAKeyPair( ); + byte[] plaintext = Encoding.UTF8.GetBytes( "Secret" ); + byte[] encrypted = EncryptionProvider.HybridEncrypt( plaintext, s_keyPair.PublicKey ); + + _ = Assert.ThrowsExactly( + ( ) => EncryptionProvider.HybridDecrypt( encrypted, wrongKeyPair.PrivateKey ) ); + } + + [TestMethod] + public void HybridDecrypt_TamperedEnvelope_ThrowsWerkrCryptoException( ) { + byte[] plaintext = Encoding.UTF8.GetBytes( "Tamper test" ); + byte[] encrypted = EncryptionProvider.HybridEncrypt( plaintext, s_keyPair.PublicKey ); + + // Flip a byte in the ciphertext region (after RSA block + nonce + tag) + int tamperIndex = EncryptionProvider.RsaEncryptedBlockSize + + EncryptionProvider.AesGcmNonceSize + + EncryptionProvider.AesGcmTagSize; + encrypted[tamperIndex] ^= 0xFF; + + _ = Assert.ThrowsExactly( + ( ) => EncryptionProvider.HybridDecrypt( encrypted, s_keyPair.PrivateKey ) ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Cryptography/PlatformValidationTests.cs b/src/Test/Werkr.Tests.Data/Unit/Cryptography/PlatformValidationTests.cs new file mode 100644 index 0000000..887325b --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Cryptography/PlatformValidationTests.cs @@ -0,0 +1,12 @@ +using Werkr.Core.Cryptography; + +namespace Werkr.Tests.Data.Unit.Cryptography; + +[TestClass] +public class PlatformValidationTests { + [TestMethod] + public void ValidatePlatformCryptoSupport_OnSupportedPlatform_DoesNotThrow( ) { + // Should not throw — SHA-512 and RSA OAEP SHA-512 are supported on all platforms. + EncryptionProvider.ValidatePlatformCryptoSupport( ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Ranges/IntRangeTests.cs b/src/Test/Werkr.Tests.Data/Unit/Ranges/IntRangeTests.cs new file mode 100644 index 0000000..32dcb22 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Ranges/IntRangeTests.cs @@ -0,0 +1,113 @@ +using Werkr.Data.Ranges; + +namespace Werkr.Tests.Data.Unit.Ranges; + +[TestClass] +public class IntRangeTests { + + #region Constructor & Properties + + [TestMethod] + public void DefaultConstructor_StartReturnsMinValue( ) { + IntRange range = new( ); + Assert.AreEqual( int.MinValue, range.Start ); + } + + [TestMethod] + public void DefaultConstructor_EndReturnsMaxValue( ) { + IntRange range = new( ); + Assert.AreEqual( int.MaxValue, range.End ); + } + + [TestMethod] + public void ExplicitConstructor_SetsStartAndEnd( ) { + IntRange range = new( 5, 10 ); + Assert.AreEqual( 5, range.Start ); + Assert.AreEqual( 10, range.End ); + } + + [TestMethod] + public void SetStart_UpdatesStartProperty( ) { + IntRange range = new( ); + range.SetStart( 42 ); + Assert.AreEqual( 42, range.Start ); + } + + [TestMethod] + public void SetEnd_UpdatesEndProperty( ) { + IntRange range = new( ); + range.SetEnd( 99 ); + Assert.AreEqual( 99, range.End ); + } + + #endregion Constructor & Properties + + #region ToString + + [TestMethod] + public void ToString_SingleValue_ReturnsOneNumber( ) { + IntRange range = new( 7, 7 ); + Assert.AreEqual( "7", range.ToString( ) ); + } + + [TestMethod] + public void ToString_Range_ReturnsDashSeparated( ) { + IntRange range = new( 3, 8 ); + Assert.AreEqual( "3 - 8", range.ToString( ) ); + } + + [TestMethod] + public void ToString_MultipleRanges_ReturnsCommaSeparated( ) { + IntRange[] ranges = [new( 1, 3 ), new( 10, 10 ), new( 7, 9 )]; + string result = IntRange.ToString( ranges ); + Assert.AreEqual( "1 - 3, 7 - 9, 10", result ); + } + + #endregion ToString + + #region GetContiguousRanges + + [TestMethod] + public void GetContiguousRanges_SingleValue_ReturnsSingleRange( ) { + List list = [.. IntRange.GetContiguousRanges( [5] )]; + Assert.HasCount( 1, list ); + Assert.AreEqual( 5, list[0].Start ); + Assert.AreEqual( 5, list[0].End ); + } + + [TestMethod] + public void GetContiguousRanges_ContiguousSequence_ReturnsSingleRange( ) { + List list = [.. IntRange.GetContiguousRanges( [1, 2, 3, 4, 5] )]; + Assert.HasCount( 1, list ); + Assert.AreEqual( 1, list[0].Start ); + Assert.AreEqual( 5, list[0].End ); + } + + [TestMethod] + public void GetContiguousRanges_TwoGaps_ReturnsThreeRanges( ) { + List list = [.. IntRange.GetContiguousRanges( [1, 2, 5, 6, 7, 10] )]; + Assert.HasCount( 3, list ); + Assert.AreEqual( 1, list[0].Start ); + Assert.AreEqual( 2, list[0].End ); + Assert.AreEqual( 5, list[1].Start ); + Assert.AreEqual( 7, list[1].End ); + Assert.AreEqual( 10, list[2].Start ); + Assert.AreEqual( 10, list[2].End ); + } + + [TestMethod] + public void GetContiguousRanges_UnsortedDuplicates_SortsAndDeduplicates( ) { + List list = [.. IntRange.GetContiguousRanges( [3, 1, 2, 2, 3] )]; + Assert.HasCount( 1, list ); + Assert.AreEqual( 1, list[0].Start ); + Assert.AreEqual( 3, list[0].End ); + } + + [TestMethod] + public void GetContiguousRanges_AllDisjoint_ReturnsOneRangePerValue( ) { + List list = [.. IntRange.GetContiguousRanges( [1, 3, 5] )]; + Assert.HasCount( 3, list ); + } + + #endregion GetContiguousRanges +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfDaysTests.cs b/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfDaysTests.cs new file mode 100644 index 0000000..568a873 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfDaysTests.cs @@ -0,0 +1,87 @@ +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Ranges; + +namespace Werkr.Tests.Data.Unit.Ranges; + +[TestClass] +public class RangeOfDaysTests { + + #region GetContiguousRanges + + [TestMethod] + public void GetContiguousRanges_SingleDay_ReturnsSingleRange( ) { + List ranges = [.. RangeOfDays + .GetContiguousRanges( DaysOfWeek.Wednesday )]; + + Assert.HasCount( 1, ranges ); + Assert.AreEqual( DayOfWeek.Wednesday, ranges[0].Start ); + Assert.AreEqual( DayOfWeek.Wednesday, ranges[0].End ); + } + + [TestMethod] + public void GetContiguousRanges_ContiguousDays_ReturnsSingleRange( ) { + DaysOfWeek weekdays = DaysOfWeek.Monday | DaysOfWeek.Tuesday | DaysOfWeek.Wednesday + | DaysOfWeek.Thursday | DaysOfWeek.Friday; + List ranges = [.. RangeOfDays + .GetContiguousRanges( weekdays )]; + + Assert.HasCount( 1, ranges ); + Assert.AreEqual( DayOfWeek.Monday, ranges[0].Start ); + Assert.AreEqual( DayOfWeek.Friday, ranges[0].End ); + } + + [TestMethod] + public void GetContiguousRanges_MondayWednesdayFriday_ReturnsThreeRanges( ) { + DaysOfWeek days = DaysOfWeek.Monday | DaysOfWeek.Wednesday | DaysOfWeek.Friday; + List ranges = [.. RangeOfDays + .GetContiguousRanges( days )]; + + Assert.HasCount( 3, ranges ); + } + + [TestMethod] + public void GetContiguousRanges_AllDays_ReturnsSingleRange( ) { + DaysOfWeek all = DaysOfWeek.Monday | DaysOfWeek.Tuesday | DaysOfWeek.Wednesday + | DaysOfWeek.Thursday | DaysOfWeek.Friday | DaysOfWeek.Saturday + | DaysOfWeek.Sunday; + List ranges = [.. RangeOfDays + .GetContiguousRanges( all )]; + + Assert.HasCount( 1, ranges ); + Assert.AreEqual( DayOfWeek.Sunday, ranges[0].Start ); + Assert.AreEqual( DayOfWeek.Saturday, ranges[0].End ); + } + + #endregion GetContiguousRanges + + #region ToString + + [TestMethod] + public void ToString_SingleDay_ReturnsAbbreviatedName( ) { + RangeOfDays range = new( ); + range.SetStart( (int)DayOfWeek.Monday ); + range.SetEnd( (int)DayOfWeek.Monday ); + string result = range.ToString( abbreviated: true ); + Assert.AreEqual( "Mon", result ); + } + + [TestMethod] + public void ToString_Range_ReturnsDashSeparated( ) { + RangeOfDays range = new( ); + range.SetStart( (int)DayOfWeek.Monday ); + range.SetEnd( (int)DayOfWeek.Friday ); + string result = range.ToString( abbreviated: true ); + Assert.AreEqual( "Mon - Fri", result ); + } + + [TestMethod] + public void ToString_FullNames_ReturnsFullNames( ) { + RangeOfDays range = new( ); + range.SetStart( (int)DayOfWeek.Monday ); + range.SetEnd( (int)DayOfWeek.Monday ); + string result = range.ToString( abbreviated: false ); + Assert.AreEqual( "Monday", result ); + } + + #endregion ToString +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfMonthsTests.cs b/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfMonthsTests.cs new file mode 100644 index 0000000..4e529b5 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfMonthsTests.cs @@ -0,0 +1,97 @@ +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Ranges; + +namespace Werkr.Tests.Data.Unit.Ranges; + +[TestClass] +public class RangeOfMonthsTests { + + #region GetContiguousRanges + + [TestMethod] + public void GetContiguousRanges_SingleMonth_ReturnsSingleRange( ) { + List ranges = [.. RangeOfMonths + .GetContiguousRanges( MonthsOfYear.March )]; + + Assert.HasCount( 1, ranges ); + Assert.AreEqual( Month.March, ranges[0].Start ); + Assert.AreEqual( Month.March, ranges[0].End ); + } + + [TestMethod] + public void GetContiguousRanges_FirstQuarter_ReturnsSingleRange( ) { + MonthsOfYear q1 = MonthsOfYear.January | MonthsOfYear.February | MonthsOfYear.March; + List ranges = [.. RangeOfMonths + .GetContiguousRanges( q1 )]; + + Assert.HasCount( 1, ranges ); + Assert.AreEqual( Month.January, ranges[0].Start ); + Assert.AreEqual( Month.March, ranges[0].End ); + } + + [TestMethod] + public void GetContiguousRanges_Quarterly_ReturnsFourRanges( ) { + MonthsOfYear quarterly = MonthsOfYear.January | MonthsOfYear.April + | MonthsOfYear.July | MonthsOfYear.October; + List ranges = [.. RangeOfMonths + .GetContiguousRanges( quarterly )]; + + Assert.HasCount( 4, ranges ); + } + + [TestMethod] + public void GetContiguousRanges_AllMonths_ReturnsSingleRange( ) { + MonthsOfYear all = MonthsOfYear.January | MonthsOfYear.February | MonthsOfYear.March + | MonthsOfYear.April | MonthsOfYear.May | MonthsOfYear.June + | MonthsOfYear.July | MonthsOfYear.August | MonthsOfYear.September + | MonthsOfYear.October | MonthsOfYear.November | MonthsOfYear.December; + List ranges = [.. RangeOfMonths + .GetContiguousRanges( all )]; + + Assert.HasCount( 1, ranges ); + Assert.AreEqual( Month.January, ranges[0].Start ); + Assert.AreEqual( Month.December, ranges[0].End ); + } + + [TestMethod] + public void GetContiguousRanges_JanMaySep_ReturnsThreeRanges( ) { + MonthsOfYear months = MonthsOfYear.January | MonthsOfYear.May | MonthsOfYear.September; + List ranges = [.. RangeOfMonths + .GetContiguousRanges( months )]; + + Assert.HasCount( 3, ranges ); + } + + #endregion GetContiguousRanges + + #region ToString + + [TestMethod] + public void ToString_SingleMonth_ReturnsAbbreviatedName( ) { + RangeOfMonths range = new( ); + range.SetStart( 6 ); + range.SetEnd( 6 ); + string result = range.ToString( abbreviated: true ); + Assert.AreEqual( "Jun", result ); + } + + [TestMethod] + public void ToString_Range_ReturnsDashSeparated( ) { + RangeOfMonths range = new( ); + range.SetStart( 1 ); + range.SetEnd( 3 ); + string result = range.ToString( abbreviated: true ); + Assert.AreEqual( "Jan - Mar", result ); + } + + [TestMethod] + public void ToString_FullNames_ReturnsFullNames( ) { + RangeOfMonths range = new( ); + range.SetStart( 12 ); + range.SetEnd( 12 ); + string result = range.ToString( abbreviated: false ); + Assert.AreEqual( "December", result ); + } + + #endregion ToString +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfWeekNumsTests.cs b/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfWeekNumsTests.cs new file mode 100644 index 0000000..7d70df9 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfWeekNumsTests.cs @@ -0,0 +1,85 @@ +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Ranges; + +namespace Werkr.Tests.Data.Unit.Ranges; + +[TestClass] +public class RangeOfWeekNumsTests { + + #region GetContiguousRanges + + [TestMethod] + public void GetContiguousRanges_FirstOnly_ReturnsSingleRange( ) { + List ranges = [.. RangeOfWeekNums + .GetContiguousRanges( WeekNumberWithinMonth.First )]; + + Assert.HasCount( 1, ranges ); + Assert.AreEqual( WeekNumberWithinMonth.First, ranges[0].Start ); + Assert.AreEqual( WeekNumberWithinMonth.First, ranges[0].End ); + } + + [TestMethod] + public void GetContiguousRanges_FirstThroughThird_ReturnsSingleRange( ) { + WeekNumberWithinMonth weeks = WeekNumberWithinMonth.First | WeekNumberWithinMonth.Second + | WeekNumberWithinMonth.Third; + List ranges = [.. RangeOfWeekNums + .GetContiguousRanges( weeks )]; + + Assert.HasCount( 1, ranges ); + Assert.AreEqual( WeekNumberWithinMonth.First, ranges[0].Start ); + Assert.AreEqual( WeekNumberWithinMonth.Third, ranges[0].End ); + } + + [TestMethod] + public void GetContiguousRanges_FirstAndFifth_ReturnsTwoRanges( ) { + WeekNumberWithinMonth weeks = WeekNumberWithinMonth.First | WeekNumberWithinMonth.Fifth; + List ranges = [.. RangeOfWeekNums + .GetContiguousRanges( weeks )]; + + Assert.HasCount( 2, ranges ); + } + + [TestMethod] + public void GetContiguousRanges_AllSixWeeks_ReturnsSingleRange( ) { + WeekNumberWithinMonth all = WeekNumberWithinMonth.First | WeekNumberWithinMonth.Second + | WeekNumberWithinMonth.Third | WeekNumberWithinMonth.Fourth + | WeekNumberWithinMonth.Fifth | WeekNumberWithinMonth.Sixth; + List ranges = [.. RangeOfWeekNums + .GetContiguousRanges( all )]; + + Assert.HasCount( 1, ranges ); + } + + #endregion GetContiguousRanges + + #region ToString + + [TestMethod] + public void ToString_SingleWeek_Abbreviated_ReturnsNumber( ) { + RangeOfWeekNums range = new( ); + range.SetStart( (int)WeekNumberWithinMonth.Third ); + range.SetEnd( (int)WeekNumberWithinMonth.Third ); + string result = range.ToString( abbreviated: true ); + Assert.AreEqual( "3", result ); + } + + [TestMethod] + public void ToString_SingleWeek_FullName_ReturnsEnumName( ) { + RangeOfWeekNums range = new( ); + range.SetStart( (int)WeekNumberWithinMonth.Second ); + range.SetEnd( (int)WeekNumberWithinMonth.Second ); + string result = range.ToString( abbreviated: false ); + Assert.AreEqual( "Second", result ); + } + + [TestMethod] + public void ToString_Range_Abbreviated_ReturnsDashSeparated( ) { + RangeOfWeekNums range = new( ); + range.SetStart( (int)WeekNumberWithinMonth.First ); + range.SetEnd( (int)WeekNumberWithinMonth.Fourth ); + string result = range.ToString( abbreviated: true ); + Assert.AreEqual( "1 - 4", result ); + } + + #endregion ToString +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs new file mode 100644 index 0000000..0d44ddf --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs @@ -0,0 +1,157 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Common.Models; +using Werkr.Core.Cryptography; +using Werkr.Core.Registration; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Tests.Data.Unit.Registration; + +[TestClass] +public class BundleExpirationServiceTests { + private SqliteConnection _connection = null!; + private ServiceProvider _serviceProvider = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + ServiceCollection services = new( ); + _ = services.AddDbContext( opt => opt.UseSqlite( _connection ) ); + _ = services.AddScoped( sp => sp.GetRequiredService( ) ); + _serviceProvider = services.BuildServiceProvider( ); + + // Create schema + using IServiceScope scope = _serviceProvider.CreateScope( ); + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + _ = db.Database.EnsureCreated( ); + } + + [TestCleanup] + public void TestCleanup( ) { + _serviceProvider?.Dispose( ); + _connection?.Dispose( ); + } + + [TestMethod] + public async Task ExecuteAsync_ExpiredPendingBundles_TransitionsToExpired( ) { + // Seed an expired pending bundle + using (IServiceScope scope = _serviceProvider.CreateScope( )) { + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + _ = db.RegistrationBundles.Add( new RegistrationBundle { + ConnectionName = "Stale", + BundleId = EncryptionProvider.GenerateRandomBytes( 16 ), + Status = RegistrationStatus.Pending, + ExpiresAt = DateTime.UtcNow.AddHours( -1 ), + KeySize = 4096, + } ); + _ = await db.SaveChangesAsync( TestContext.CancellationToken ); + } + + // Run the background service and poll until the bundle transitions + IServiceScopeFactory scopeFactory = _serviceProvider.GetRequiredService( ); + BundleExpirationService service = new( + scopeFactory, + NullLogger.Instance, + interval: TimeSpan.FromMilliseconds( 50 ) ); + + using CancellationTokenSource cts = new( ); + await service.StartAsync( cts.Token ); + + // Poll until expired or timeout (max 5 seconds) + RegistrationStatus status = RegistrationStatus.Pending; + DateTime deadline = DateTime.UtcNow.AddSeconds( 5 ); + while (status == RegistrationStatus.Pending && DateTime.UtcNow < deadline) { + await Task.Delay( 100, TestContext.CancellationToken ); + using IServiceScope pollScope = _serviceProvider.CreateScope( ); + WerkrDbContext pollDb = pollScope.ServiceProvider.GetRequiredService( ); + RegistrationBundle polled = await pollDb.RegistrationBundles + .AsNoTracking( ) + .SingleAsync( TestContext.CancellationToken ); + status = polled.Status; + } + + cts.Cancel( ); + await service.StopAsync( TestContext.CancellationToken ); + + // Verify + Assert.AreEqual( RegistrationStatus.Expired, status ); + } + + [TestMethod] + public async Task ExecuteAsync_NonPendingBundles_NotModified( ) { + // Seed a completed bundle (expired in the past but already completed) + using (IServiceScope scope = _serviceProvider.CreateScope( )) { + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + _ = db.RegistrationBundles.Add( new RegistrationBundle { + ConnectionName = "AlreadyDone", + BundleId = EncryptionProvider.GenerateRandomBytes( 16 ), + Status = RegistrationStatus.Completed, + ExpiresAt = DateTime.UtcNow.AddHours( -1 ), + KeySize = 4096, + } ); + _ = await db.SaveChangesAsync( TestContext.CancellationToken ); + } + + IServiceScopeFactory scopeFactory = _serviceProvider.GetRequiredService( ); + BundleExpirationService service = new( + scopeFactory, + NullLogger.Instance, + interval: TimeSpan.FromMilliseconds( 50 ) ); + + using CancellationTokenSource cts = new( ); + await service.StartAsync( cts.Token ); + await Task.Delay( 300, TestContext.CancellationToken ); + cts.Cancel( ); + await service.StopAsync( TestContext.CancellationToken ); + + using (IServiceScope scope = _serviceProvider.CreateScope( )) { + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + RegistrationBundle bundle = await db.RegistrationBundles + .SingleAsync( TestContext.CancellationToken ); + Assert.AreEqual( RegistrationStatus.Completed, bundle.Status ); + } + } + + [TestMethod] + public async Task ExecuteAsync_UnexpiredBundles_NotModified( ) { + // Seed a pending bundle that has not yet expired + using (IServiceScope scope = _serviceProvider.CreateScope( )) { + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + _ = db.RegistrationBundles.Add( new RegistrationBundle { + ConnectionName = "Fresh", + BundleId = EncryptionProvider.GenerateRandomBytes( 16 ), + Status = RegistrationStatus.Pending, + ExpiresAt = DateTime.UtcNow.AddHours( 24 ), + KeySize = 4096, + } ); + _ = await db.SaveChangesAsync( TestContext.CancellationToken ); + } + + IServiceScopeFactory scopeFactory = _serviceProvider.GetRequiredService( ); + BundleExpirationService service = new( + scopeFactory, + NullLogger.Instance, + interval: TimeSpan.FromMilliseconds( 50 ) ); + + using CancellationTokenSource cts = new( ); + await service.StartAsync( cts.Token ); + await Task.Delay( 300, TestContext.CancellationToken ); + cts.Cancel( ); + await service.StopAsync( TestContext.CancellationToken ); + + using (IServiceScope scope = _serviceProvider.CreateScope( )) { + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + RegistrationBundle bundle = await db.RegistrationBundles + .SingleAsync( TestContext.CancellationToken ); + Assert.AreEqual( RegistrationStatus.Pending, bundle.Status ); + } + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationBundleGeneratorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationBundleGeneratorTests.cs new file mode 100644 index 0000000..133b706 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationBundleGeneratorTests.cs @@ -0,0 +1,65 @@ +using Werkr.Common.Models; +using Werkr.Core.Registration; +using Werkr.Core.Registration.Models; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Tests.Data.Unit.Registration; + +[TestClass] +public class RegistrationBundleGeneratorTests { + [TestMethod] + public void CreateBundle_DefaultSettings_ProducesValidEntity( ) { + (string encrypted, RegistrationBundle entity) = RegistrationBundleGenerator.CreateBundle( + "TestConn", "https://server:5000", "password123" ); + + Assert.IsNotNull( encrypted ); + Assert.IsNotNull( entity ); + Assert.AreEqual( "TestConn", entity.ConnectionName ); + Assert.HasCount( 16, entity.BundleId ); + Assert.AreEqual( RegistrationStatus.Pending, entity.Status ); + Assert.AreEqual( 4096, entity.KeySize ); + } + + [TestMethod] + public void CreateBundle_DefaultExpiration_ExpiresIn24Hours( ) { + DateTime before = DateTime.UtcNow.AddHours( 24 ); + + (_, RegistrationBundle entity) = RegistrationBundleGenerator.CreateBundle( + "Conn", "https://server", "pass" ); + + DateTime after = DateTime.UtcNow.AddHours( 24 ); + + // MSTest v4: IsGreaterThanOrEqualTo(lowerBound, value) asserts value >= lowerBound + Assert.IsGreaterThanOrEqualTo( before, entity.ExpiresAt ); + Assert.IsLessThanOrEqualTo( after, entity.ExpiresAt ); + } + + [TestMethod] + public void CreateBundle_CustomExpiration_SetsCorrectExpiry( ) { + TimeSpan customExpiration = TimeSpan.FromMinutes( 30 ); + DateTime before = DateTime.UtcNow.AddMinutes( 30 ); + + (_, RegistrationBundle entity) = RegistrationBundleGenerator.CreateBundle( + "Conn", "https://server", "pass", expiration: customExpiration ); + + DateTime after = DateTime.UtcNow.AddMinutes( 30 ); + + // MSTest v4: IsGreaterThanOrEqualTo(lowerBound, value) asserts value >= lowerBound + Assert.IsGreaterThanOrEqualTo( before, entity.ExpiresAt ); + Assert.IsLessThanOrEqualTo( after, entity.ExpiresAt ); + } + + [TestMethod] + public void CreateBundle_EncryptedBundle_DecryptableWithPassword( ) { + string password = "MySecurePass!"; + + (string encrypted, RegistrationBundle entity) = RegistrationBundleGenerator.CreateBundle( + "TestConn", "https://server:5000", password ); + + RegistrationBundlePayload payload = RegistrationBundlePayload.FromEncryptedString( encrypted, password ); + + CollectionAssert.AreEqual( entity.BundleId, payload.BundleId ); + Assert.AreEqual( "TestConn", payload.ConnectionName ); + Assert.AreEqual( "https://server:5000", payload.ServerUrl ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationBundlePayloadTests.cs b/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationBundlePayloadTests.cs new file mode 100644 index 0000000..9edaf83 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationBundlePayloadTests.cs @@ -0,0 +1,52 @@ +using Werkr.Core.Cryptography; +using Werkr.Core.Registration.Models; + +namespace Werkr.Tests.Data.Unit.Registration; + +[TestClass] +public class RegistrationBundlePayloadTests { + [TestMethod] + public void ToFromEncryptedString_RoundTrip_PreservesAllFields( ) { + string password = "TestPassword123!"; + byte[] bundleId = EncryptionProvider.GenerateRandomBytes( 16 ); + byte[] serverPubKeyBytes = new byte[64]; + Random.Shared.NextBytes( serverPubKeyBytes ); + + RegistrationBundlePayload original = new( + bundleId, "TestConnection", "https://server:5000", serverPubKeyBytes ); + + string encrypted = original.ToEncryptedString( password ); + RegistrationBundlePayload restored = RegistrationBundlePayload.FromEncryptedString( encrypted, password ); + + CollectionAssert.AreEqual( original.BundleId, restored.BundleId ); + Assert.AreEqual( original.ConnectionName, restored.ConnectionName ); + Assert.AreEqual( original.ServerUrl, restored.ServerUrl ); + CollectionAssert.AreEqual( original.ServerPublicKeyBytes, restored.ServerPublicKeyBytes ); + } + + [TestMethod] + public void FromEncryptedString_WrongPassword_ThrowsWerkrCryptoException( ) { + byte[] bundleId = EncryptionProvider.GenerateRandomBytes( 16 ); + byte[] keyBytes = new byte[64]; + RegistrationBundlePayload payload = new( bundleId, "Conn", "https://srv", keyBytes ); + string encrypted = payload.ToEncryptedString( "CorrectPassword" ); + + _ = Assert.ThrowsExactly( + ( ) => RegistrationBundlePayload.FromEncryptedString( encrypted, "WrongPassword" ) ); + } + + [TestMethod] + public void FromEncryptedString_CorruptedData_ThrowsWerkrCryptoException( ) { + // Valid Base64 that is not a valid encrypted bundle + string corrupted = Convert.ToBase64String( new byte[100] ); + + _ = Assert.ThrowsExactly( + ( ) => RegistrationBundlePayload.FromEncryptedString( corrupted, "password" ) ); + } + + [TestMethod] + public void FromEncryptedString_EmptyString_ThrowsArgumentException( ) { + _ = Assert.ThrowsExactly( + ( ) => RegistrationBundlePayload.FromEncryptedString( "", "password" ) ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationServiceTests.cs new file mode 100644 index 0000000..cbede5e --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationServiceTests.cs @@ -0,0 +1,229 @@ +using System.Security.Cryptography; +using System.Text.Json; + +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Common.Models; +using Werkr.Core.Cryptography; +using Werkr.Core.Cryptography.KeyInfo; +using Werkr.Core.Registration; +using Werkr.Core.Registration.Models; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Tests.Data.Unit.Registration; + +[TestClass] +public class RegistrationServiceTests { + private static RSAKeyPair s_serverKeys = null!; + private static RSAKeyPair s_agentKeys = null!; + + private SqliteConnection _connection = null!; + private SqliteWerkrDbContext _dbContext = null!; + private RegistrationService _service = null!; + + public TestContext TestContext { get; set; } = null!; + + [ClassInitialize] + public static void ClassInit( TestContext context ) { + // Pre-generate RSA-4096 keys to avoid per-test overhead. + s_serverKeys = EncryptionProvider.GenerateRSAKeyPair( ); + s_agentKeys = EncryptionProvider.GenerateRSAKeyPair( ); + } + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + + _service = new RegistrationService( + _dbContext, + NullLogger.Instance, + "https://server:5000" ); + } + + [TestCleanup] + public void TestCleanup( ) { + _dbContext?.Dispose( ); + _connection?.Dispose( ); + } + + // -- GenerateBundleAsync -- + + [TestMethod] + public async Task GenerateBundleAsync_PersistsBundleToDatabase( ) { + string encrypted = await _service.GenerateBundleAsync( + "TestConn", "password123", TimeSpan.FromHours( 1 ), null, TestContext.CancellationToken ); + + Assert.IsNotNull( encrypted ); + + List bundles = await _dbContext.RegistrationBundles + .ToListAsync( TestContext.CancellationToken ); + + Assert.HasCount( 1, bundles ); + Assert.AreEqual( "TestConn", bundles[0].ConnectionName ); + Assert.AreEqual( RegistrationStatus.Pending, bundles[0].Status ); + } + + // -- CompleteRegistrationAsync -- + + [TestMethod] + public async Task CompleteRegistrationAsync_ValidBundle_Succeeds( ) { + RegistrationBundle bundle = SeedPendingBundle( ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + byte[] encryptedAgentKey = BuildEncryptedAgentPublicKey( bundle.ServerPublicKey ); + + (AgentRegistrationResult result, byte[]? encryptedResponse) = await _service.CompleteRegistrationAsync( + bundle.BundleId, encryptedAgentKey, "https://agent:5001", "TestAgent", + TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsNotNull( result.ApiKey ); + Assert.IsNotNull( result.SharedKey ); + Assert.IsNotNull( encryptedResponse ); + } + + [TestMethod] + public async Task CompleteRegistrationAsync_ExpiredBundle_ReturnsFailure( ) { + RegistrationBundle bundle = SeedPendingBundle( ); + bundle.ExpiresAt = DateTime.UtcNow.AddHours( -1 ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + byte[] encryptedAgentKey = BuildEncryptedAgentPublicKey( bundle.ServerPublicKey ); + + (AgentRegistrationResult result, byte[]? encryptedResponse) = await _service.CompleteRegistrationAsync( + bundle.BundleId, encryptedAgentKey, "https://agent:5001", "TestAgent", + TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNull( encryptedResponse ); + } + + [TestMethod] + public async Task CompleteRegistrationAsync_CompletedBundle_ReturnsFailure( ) { + RegistrationBundle bundle = SeedPendingBundle( ); + bundle.Status = RegistrationStatus.Completed; + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + byte[] encryptedAgentKey = BuildEncryptedAgentPublicKey( bundle.ServerPublicKey ); + + (AgentRegistrationResult result, byte[]? encryptedResponse) = await _service.CompleteRegistrationAsync( + bundle.BundleId, encryptedAgentKey, "https://agent:5001", "TestAgent", + TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNull( encryptedResponse ); + } + + [TestMethod] + public async Task CompleteRegistrationAsync_UnknownBundle_ReturnsFailure( ) { + byte[] unknownBundleId = EncryptionProvider.GenerateRandomBytes( 16 ); + byte[] encryptedAgentKey = BuildEncryptedAgentPublicKey( s_serverKeys.PublicKey ); + + (AgentRegistrationResult result, byte[]? encryptedResponse) = await _service.CompleteRegistrationAsync( + unknownBundleId, encryptedAgentKey, "https://agent:5001", "TestAgent", + TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNull( encryptedResponse ); + } + + [TestMethod] + public async Task CompleteRegistrationAsync_EncryptedResponse_DecryptableByAgent( ) { + RegistrationBundle bundle = SeedPendingBundle( ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + byte[] encryptedAgentKey = BuildEncryptedAgentPublicKey( bundle.ServerPublicKey ); + + (AgentRegistrationResult result, byte[]? encryptedResponse) = await _service.CompleteRegistrationAsync( + bundle.BundleId, encryptedAgentKey, "https://agent:5001", "TestAgent", + TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsNotNull( encryptedResponse ); + + // Agent should be able to decrypt the response with its private key + byte[] responseJson = EncryptionProvider.HybridDecrypt( encryptedResponse, s_agentKeys.PrivateKey ); + RegistrationResponsePayload? responsePayload = JsonSerializer.Deserialize( responseJson ); + + Assert.IsNotNull( responsePayload ); + Assert.AreEqual( result.ApiKey, responsePayload.AgentToServerApiKey ); + } + + [TestMethod] + public async Task CompleteRegistrationAsync_CreatesRegisteredConnection( ) { + RegistrationBundle bundle = SeedPendingBundle( ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + byte[] encryptedAgentKey = BuildEncryptedAgentPublicKey( bundle.ServerPublicKey ); + + _ = await _service.CompleteRegistrationAsync( + bundle.BundleId, encryptedAgentKey, "https://agent:5001", "TestAgent", + TestContext.CancellationToken ); + + List connections = await _dbContext.RegisteredConnections + .ToListAsync( TestContext.CancellationToken ); + + Assert.HasCount( 1, connections ); + Assert.AreEqual( "TestConn", connections[0].ConnectionName ); + Assert.AreEqual( "https://agent:5001", connections[0].RemoteUrl ); + Assert.AreEqual( ConnectionStatus.Connected, connections[0].Status ); + Assert.IsTrue( connections[0].IsServer ); + } + + [TestMethod] + public async Task CompleteRegistrationAsync_StoresHashedApiKey( ) { + RegistrationBundle bundle = SeedPendingBundle( ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + byte[] encryptedAgentKey = BuildEncryptedAgentPublicKey( bundle.ServerPublicKey ); + + (AgentRegistrationResult result, _) = await _service.CompleteRegistrationAsync( + bundle.BundleId, encryptedAgentKey, "https://agent:5001", "TestAgent", + TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsNotNull( result.ApiKey ); + + RegisteredConnection connection = await _dbContext.RegisteredConnections + .SingleAsync( TestContext.CancellationToken ); + + // Server stores SHA-512 hash, not raw API key + string expectedHash = EncryptionProvider.HashSHA512String( result.ApiKey ); + Assert.AreEqual( expectedHash, connection.InboundApiKeyHash ); + + // Hash should be 128 hex chars (SHA-512 = 64 bytes) + Assert.HasCount( 128, connection.InboundApiKeyHash ); + } + + // -- Helpers -- + + private RegistrationBundle SeedPendingBundle( ) { + RegistrationBundle bundle = new( ) { + ConnectionName = "TestConn", + ServerPublicKey = s_serverKeys.PublicKey, + ServerPrivateKey = s_serverKeys.PrivateKey, + BundleId = EncryptionProvider.GenerateRandomBytes( 16 ), + Status = RegistrationStatus.Pending, + ExpiresAt = DateTime.UtcNow.AddHours( 1 ), + KeySize = 4096, + }; + _ = _dbContext.RegistrationBundles.Add( bundle ); + return bundle; + } + + private static byte[] BuildEncryptedAgentPublicKey( RSAParameters serverPublicKey ) { + byte[] agentPubKeyBytes = EncryptionProvider.SerializePublicKey( s_agentKeys.PublicKey ); + return EncryptionProvider.HybridEncrypt( agentPubKeyBytes, serverPublicKey ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/CalendarEnumExtensionsTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/CalendarEnumExtensionsTests.cs new file mode 100644 index 0000000..a863a75 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/CalendarEnumExtensionsTests.cs @@ -0,0 +1,245 @@ +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Extensions; + +namespace Werkr.Tests.Data.Unit.Scheduling; + +[TestClass] +public class CalendarEnumExtensionsTests { + + #region DaysOfWeek / DayOfWeek + + [TestMethod] + public void GetDaysOfWeek_AllDays_ReturnsSevenDays( ) { + DaysOfWeek allDays = (DaysOfWeek)127; + List result = allDays.GetDaysOfWeek(); + Assert.HasCount( 7, result ); + Assert.AreEqual( DayOfWeek.Monday, result[0] ); + Assert.AreEqual( DayOfWeek.Sunday, result[6] ); + } + + [TestMethod] + public void GetDaysOfWeek_None_ReturnsEmptyList( ) { + DaysOfWeek none = DaysOfWeek.None; + List result = none.GetDaysOfWeek(); + Assert.IsEmpty( result ); + } + + [TestMethod] + public void GetDaysOfWeek_SingleDay_ReturnsSingleDay( ) { + DaysOfWeek wednesday = DaysOfWeek.Wednesday; + List result = wednesday.GetDaysOfWeek(); + Assert.HasCount( 1, result ); + Assert.AreEqual( DayOfWeek.Wednesday, result[0] ); + } + + [TestMethod] + public void GetDaysOfWeek_MWF_ReturnsThreeDays( ) { + DaysOfWeek mwf = DaysOfWeek.Monday | DaysOfWeek.Wednesday | DaysOfWeek.Friday; + List result = mwf.GetDaysOfWeek(); + Assert.HasCount( 3, result ); + Assert.AreEqual( DayOfWeek.Monday, result[0] ); + Assert.AreEqual( DayOfWeek.Wednesday, result[1] ); + Assert.AreEqual( DayOfWeek.Friday, result[2] ); + } + + [TestMethod] + public void GetWeekOfDays_ReturnsSevenDaysStartingFromWeekStartDay( ) { + DayOfWeek savedStart = CalendarEnumExtensions.WeekStartDay; + try { + CalendarEnumExtensions.WeekStartDay = DayOfWeek.Monday; + List result = CalendarEnumExtensions.GetWeekOfDays(); + Assert.HasCount( 7, result ); + Assert.AreEqual( DayOfWeek.Monday, result[0] ); + Assert.AreEqual( DayOfWeek.Sunday, result[6] ); + } finally { + CalendarEnumExtensions.WeekStartDay = savedStart; + } + } + + [TestMethod] + public void GetWeekOfDays_SundayStart_ReturnsSundayFirst( ) { + DayOfWeek savedStart = CalendarEnumExtensions.WeekStartDay; + try { + CalendarEnumExtensions.WeekStartDay = DayOfWeek.Sunday; + List result = CalendarEnumExtensions.GetWeekOfDays(); + Assert.HasCount( 7, result ); + Assert.AreEqual( DayOfWeek.Sunday, result[0] ); + Assert.AreEqual( DayOfWeek.Saturday, result[6] ); + } finally { + CalendarEnumExtensions.WeekStartDay = savedStart; + } + } + + [TestMethod] + public void OrderDaysOfWeek_ReordersFromStartDay( ) { + DayOfWeek savedStart = CalendarEnumExtensions.WeekStartDay; + try { + CalendarEnumExtensions.WeekStartDay = DayOfWeek.Wednesday; + List days = [DayOfWeek.Monday, DayOfWeek.Wednesday, DayOfWeek.Friday]; + List result = days.OrderDaysOfWeek(); + Assert.HasCount( 3, result ); + Assert.AreEqual( DayOfWeek.Wednesday, result[0] ); + Assert.AreEqual( DayOfWeek.Friday, result[1] ); + Assert.AreEqual( DayOfWeek.Monday, result[2] ); + } finally { + CalendarEnumExtensions.WeekStartDay = savedStart; + } + } + + [TestMethod] + public void GetRemainingDaysInWeek_Inclusive_IncludesStartDay( ) { + List allDays = CalendarEnumExtensions.GetUnorderedWeekOfDays(); + List result = allDays.GetRemainingDaysInWeek( DayOfWeek.Wednesday, exclusive: false ); + Assert.Contains( DayOfWeek.Wednesday, result ); + } + + [TestMethod] + public void GetRemainingDaysInWeek_Exclusive_ExcludesStartDay( ) { + List allDays = CalendarEnumExtensions.GetUnorderedWeekOfDays(); + List result = allDays.GetRemainingDaysInWeek( DayOfWeek.Wednesday, exclusive: true ); + Assert.DoesNotContain( DayOfWeek.Wednesday, result ); + } + + [TestMethod] + public void GetNextDayInWeek_ReturnsNextMatchingDay( ) { + List mwf = [DayOfWeek.Monday, DayOfWeek.Wednesday, DayOfWeek.Friday]; + DayOfWeek? result = mwf.GetNextDayInWeek( DayOfWeek.Monday ); + Assert.AreEqual( DayOfWeek.Wednesday, result ); + } + + [TestMethod] + public void GetNextDayInWeek_LastDay_ReturnsNull( ) { + DayOfWeek savedStart = CalendarEnumExtensions.WeekStartDay; + try { + CalendarEnumExtensions.WeekStartDay = DayOfWeek.Monday; + List days = [DayOfWeek.Monday, DayOfWeek.Sunday]; + DayOfWeek? result = days.GetNextDayInWeek( DayOfWeek.Sunday ); + Assert.IsNull( result ); + } finally { + CalendarEnumExtensions.WeekStartDay = savedStart; + } + } + + [TestMethod] + public void ToString_DaysOfWeek_AbbreviatedFalse_ReturnsFullNames( ) { + DaysOfWeek monday = DaysOfWeek.Monday; + string result = monday.ToString( abbreviated: false ); + Assert.Contains( "Monday", result ); + } + + [TestMethod] + public void ToString_DaysOfWeek_AbbreviatedTrue_ReturnsShortNames( ) { + DaysOfWeek monday = DaysOfWeek.Monday; + string result = monday.ToString( abbreviated: true ); + Assert.Contains( "Mon", result ); + } + + #endregion DaysOfWeek / DayOfWeek + + + #region MonthsOfYear / Month + + [TestMethod] + public void GetMonths_AllMonths_ReturnsTwelveMonths( ) { + MonthsOfYear allMonths = (MonthsOfYear)65520; + List result = allMonths.GetMonths(); + Assert.HasCount( 12, result ); + Assert.AreEqual( Month.January, result[0] ); + Assert.AreEqual( Month.December, result[11] ); + } + + [TestMethod] + public void GetIntMonths_QuarterlyMonths_ReturnsFourInts( ) { + MonthsOfYear quarterly = MonthsOfYear.January | MonthsOfYear.April | MonthsOfYear.July | MonthsOfYear.October; + int[] result = quarterly.GetIntMonths(); + Assert.HasCount( 4, result ); + Assert.AreEqual( 1, result[0] ); + Assert.AreEqual( 4, result[1] ); + Assert.AreEqual( 7, result[2] ); + Assert.AreEqual( 10, result[3] ); + } + + [TestMethod] + public void GetRemainingMonthsInYear_Inclusive_IncludesStartMonth( ) { + int[] months = [1, 4, 7, 10]; + int[] result = months.GetRemainingMonthsInYear( 4, exclusive: false ); + CollectionAssert.Contains( result, 4 ); + } + + [TestMethod] + public void GetRemainingMonthsInYear_Exclusive_ExcludesStartMonth( ) { + int[] months = [1, 4, 7, 10]; + int[] result = months.GetRemainingMonthsInYear( 4, exclusive: true ); + CollectionAssert.DoesNotContain( result, 4 ); + Assert.HasCount( 2, result ); + } + + [TestMethod] + public void GetMonths_RoundTrip_PreservesValues( ) { + List original = [Month.March, Month.July, Month.November]; + MonthsOfYear flags = original.GetMonths(); + List roundTripped = flags.GetMonths(); + CollectionAssert.AreEqual( original, roundTripped ); + } + + [TestMethod] + public void ToString_MonthsOfYear_FullName_ContainsMonthName( ) { + MonthsOfYear january = MonthsOfYear.January; + string result = january.ToString( abbreviated: false ); + Assert.Contains( "January", result ); + } + + [TestMethod] + public void ToString_MonthsOfYear_Abbreviated_ContainsShortName( ) { + MonthsOfYear january = MonthsOfYear.January; + string result = january.ToString( abbreviated: true ); + Assert.Contains( "Jan", result ); + } + + #endregion MonthsOfYear / Month + + + #region WeekNumberWithinMonth + + [TestMethod] + public void GetWeekNumbersInMonth_AllWeeks_ReturnsSix( ) { + WeekNumberWithinMonth allWeeks = (WeekNumberWithinMonth)63; + int[] result = allWeeks.GetWeekNumbersInMonth(); + Assert.HasCount( 6, result ); + Assert.AreEqual( 1, result[0] ); + Assert.AreEqual( 6, result[5] ); + } + + [TestMethod] + public void GetWeekNumbersInMonth_None_ReturnsEmpty( ) { + WeekNumberWithinMonth none = WeekNumberWithinMonth.None; + int[] result = none.GetWeekNumbersInMonth(); + Assert.IsEmpty( result ); + } + + [TestMethod] + public void GetWeekNumbersInMonth_FirstAndThird_ReturnsTwoWeeks( ) { + WeekNumberWithinMonth weeks = WeekNumberWithinMonth.First | WeekNumberWithinMonth.Third; + int[] result = weeks.GetWeekNumbersInMonth(); + Assert.HasCount( 2, result ); + Assert.AreEqual( 1, result[0] ); + Assert.AreEqual( 3, result[1] ); + } + + [TestMethod] + public void ToString_WeekNumberWithinMonth_Abbreviated_ReturnsNumeric( ) { + WeekNumberWithinMonth first = WeekNumberWithinMonth.First; + string result = first.ToString( abbreviated: true ); + Assert.Contains( "1", result ); + } + + [TestMethod] + public void ToString_WeekNumberWithinMonth_FullName_ReturnsOrdinal( ) { + WeekNumberWithinMonth first = WeekNumberWithinMonth.First; + string result = first.ToString( abbreviated: false ); + Assert.Contains( "First", result ); + } + + #endregion WeekNumberWithinMonth + +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayCalculatorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayCalculatorTests.cs new file mode 100644 index 0000000..be92f75 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayCalculatorTests.cs @@ -0,0 +1,471 @@ +using Werkr.Core.Scheduling; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Tests.Data.Unit.Scheduling; + +[TestClass] +public class HolidayCalculatorTests { + + #region Helpers + + private static HolidayRule MakeFixedDateRule( + int month, int day, + ObservanceRule observance = ObservanceRule.None, + int? yearStart = null, int? yearEnd = null, + TimeOnly? windowStart = null, TimeOnly? windowEnd = null, + string? windowTz = null ) => new( ) { + HolidayCalendarId = Guid.NewGuid( ), + Name = $"FixedDate {month}/{day}", + RuleType = HolidayRuleType.FixedDate, + Month = month, + Day = day, + ObservanceRule = observance, + YearStart = yearStart, + YearEnd = yearEnd, + WindowStart = windowStart, + WindowEnd = windowEnd, + WindowTimeZoneId = windowTz, + }; + + private static HolidayRule MakeNthWeekdayRule( + int month, DayOfWeek dayOfWeek, int weekNumber, + int? yearStart = null, int? yearEnd = null ) => new( ) { + HolidayCalendarId = Guid.NewGuid( ), + Name = $"Nth {dayOfWeek} #{weekNumber} in month {month}", + RuleType = HolidayRuleType.NthWeekdayOfMonth, + Month = month, + DayOfWeek = dayOfWeek, + WeekNumber = weekNumber, + YearStart = yearStart, + YearEnd = yearEnd, + }; + + private static HolidayRule MakeLastWeekdayRule( + int month, DayOfWeek dayOfWeek, + int? yearStart = null, int? yearEnd = null ) => new( ) { + HolidayCalendarId = Guid.NewGuid( ), + Name = $"Last {dayOfWeek} in month {month}", + RuleType = HolidayRuleType.LastWeekdayOfMonth, + Month = month, + DayOfWeek = dayOfWeek, + YearStart = yearStart, + YearEnd = yearEnd, + }; + + private static HolidayCalendar MakeCalendar( params HolidayRule[] rules ) { + Guid calId = Guid.NewGuid( ); + foreach (HolidayRule r in rules) { + r.HolidayCalendarId = calId; + } + + return new HolidayCalendar { + Id = calId, + Name = "Test Calendar", + Rules = rules, + }; + } + + #endregion + + // ── FixedDate ────────────────────────────────────────────────────────────── + + [TestMethod] + public void FixedDate_NewYearsDay_Returns_Jan1( ) { + HolidayRule rule = MakeFixedDateRule( 1, 1 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2026, 1, 1 ), dates[0].Date ); + } + + [TestMethod] + public void FixedDate_July4_Returns_July4( ) { + HolidayRule rule = MakeFixedDateRule( 7, 4 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2026, 7, 4 ), dates[0].Date ); + } + + [TestMethod] + public void FixedDate_Dec25_Returns_Christmas( ) { + HolidayRule rule = MakeFixedDateRule( 12, 25 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2025 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2025, 12, 25 ), dates[0].Date ); + } + + // ── Observance Rules ─────────────────────────────────────────────────────── + + [TestMethod] + public void Observance_SatToFri_SunToMon_ShiftsSaturday( ) { + // July 4 2026 = Saturday + HolidayRule rule = MakeFixedDateRule( 7, 4, ObservanceRule.SaturdayToFriday_SundayToMonday ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + // Saturday → Friday July 3 + Assert.AreEqual( new DateOnly( 2026, 7, 3 ), dates[0].Date ); + } + + [TestMethod] + public void Observance_SatToFri_SunToMon_ShiftsSunday( ) { + // Jan 1 2023 = Sunday + HolidayRule rule = MakeFixedDateRule( 1, 1, ObservanceRule.SaturdayToFriday_SundayToMonday ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2023 ); + Assert.HasCount( 1, dates ); + // Sunday → Monday Jan 2 + Assert.AreEqual( new DateOnly( 2023, 1, 2 ), dates[0].Date ); + } + + [TestMethod] + public void Observance_SatToMon_ShiftsSaturday( ) { + // July 4 2026 = Saturday + HolidayRule rule = MakeFixedDateRule( 7, 4, ObservanceRule.SaturdayToMonday ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + // Saturday → Monday July 6 + Assert.AreEqual( new DateOnly( 2026, 7, 6 ), dates[0].Date ); + } + + [TestMethod] + public void Observance_SatToMon_NoShiftOnSunday( ) { + // Jan 1 2023 = Sunday + HolidayRule rule = MakeFixedDateRule( 1, 1, ObservanceRule.SaturdayToMonday ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2023 ); + Assert.HasCount( 1, dates ); + // Sunday is NOT shifted by SaturdayToMonday rule + Assert.AreEqual( new DateOnly( 2023, 1, 1 ), dates[0].Date ); + } + + [TestMethod] + public void Observance_NearestWeekday_ShiftsSaturday( ) { + // July 4 2026 = Saturday + HolidayRule rule = MakeFixedDateRule( 7, 4, ObservanceRule.NearestWeekday ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + // Saturday → Friday July 3 + Assert.AreEqual( new DateOnly( 2026, 7, 3 ), dates[0].Date ); + } + + [TestMethod] + public void Observance_NearestWeekday_ShiftsSunday( ) { + // Jan 1 2023 = Sunday + HolidayRule rule = MakeFixedDateRule( 1, 1, ObservanceRule.NearestWeekday ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2023 ); + Assert.HasCount( 1, dates ); + // Sunday → Monday Jan 2 + Assert.AreEqual( new DateOnly( 2023, 1, 2 ), dates[0].Date ); + } + + [TestMethod] + public void Observance_None_NoShift( ) { + // July 4 2026 = Saturday + HolidayRule rule = MakeFixedDateRule( 7, 4, ObservanceRule.None ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2026, 7, 4 ), dates[0].Date ); + } + + [TestMethod] + public void Observance_WeekdayNoShift( ) { + // July 4 2025 = Friday — no shift needed + HolidayRule rule = MakeFixedDateRule( 7, 4, ObservanceRule.SaturdayToFriday_SundayToMonday ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2025 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2025, 7, 4 ), dates[0].Date ); + } + + // ── NthWeekdayOfMonth ────────────────────────────────────────────────────── + + [TestMethod] + public void NthWeekday_ThirdMondayJan_MLK_2026( ) { + // MLK Day = 3rd Monday in January + HolidayRule rule = MakeNthWeekdayRule( 1, DayOfWeek.Monday, 3 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2026, 1, 19 ), dates[0].Date ); + } + + [TestMethod] + public void NthWeekday_ThirdMondayFeb_PresidentsDay_2026( ) { + // Presidents' Day = 3rd Monday in February + HolidayRule rule = MakeNthWeekdayRule( 2, DayOfWeek.Monday, 3 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2026, 2, 16 ), dates[0].Date ); + } + + [TestMethod] + public void NthWeekday_FirstMondaySept_LaborDay_2026( ) { + // Labor Day = 1st Monday in September + HolidayRule rule = MakeNthWeekdayRule( 9, DayOfWeek.Monday, 1 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2026, 9, 7 ), dates[0].Date ); + } + + [TestMethod] + public void NthWeekday_SecondMondayOct_ColumbusDay_2026( ) { + // Columbus Day = 2nd Monday in October + HolidayRule rule = MakeNthWeekdayRule( 10, DayOfWeek.Monday, 2 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2026, 10, 12 ), dates[0].Date ); + } + + [TestMethod] + public void NthWeekday_FourthThursdayNov_Thanksgiving_2026( ) { + // Thanksgiving = 4th Thursday in November + HolidayRule rule = MakeNthWeekdayRule( 11, DayOfWeek.Thursday, 4 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2026, 11, 26 ), dates[0].Date ); + } + + [TestMethod] + public void NthWeekday_FifthMondayReturnsEmpty_WhenNotEnough( ) { + // 5th Monday of February 2026 — does not exist + HolidayRule rule = MakeNthWeekdayRule( 2, DayOfWeek.Monday, 5 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 0, dates ); + } + + // ── LastWeekdayOfMonth ───────────────────────────────────────────────────── + + [TestMethod] + public void LastWeekday_LastMondayMay_MemorialDay_2026( ) { + // Memorial Day = Last Monday in May + HolidayRule rule = MakeLastWeekdayRule( 5, DayOfWeek.Monday ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2026, 5, 25 ), dates[0].Date ); + } + + [TestMethod] + public void LastWeekday_LastFridayOfJune_2026( ) { + HolidayRule rule = MakeLastWeekdayRule( 6, DayOfWeek.Friday ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2026, 6, 26 ), dates[0].Date ); + } + + // ── Year Bounds ──────────────────────────────────────────────────────────── + + [TestMethod] + public void YearBounds_Before_YearStart_ReturnsEmpty( ) { + HolidayRule rule = MakeFixedDateRule( 7, 4, yearStart: 2026 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2025 ); + Assert.HasCount( 0, dates ); + } + + [TestMethod] + public void YearBounds_After_YearEnd_ReturnsEmpty( ) { + HolidayRule rule = MakeFixedDateRule( 7, 4, yearEnd: 2025 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 0, dates ); + } + + [TestMethod] + public void YearBounds_AtBoundary_Inclusive( ) { + HolidayRule rule = MakeFixedDateRule( 7, 4, yearStart: 2025, yearEnd: 2027 ); + + Assert.HasCount( 1, HolidayCalculator.ComputeDatesForYear( rule, 2025 ) ); + Assert.HasCount( 1, HolidayCalculator.ComputeDatesForYear( rule, 2026 ) ); + Assert.HasCount( 1, HolidayCalculator.ComputeDatesForYear( rule, 2027 ) ); + } + + [TestMethod] + public void YearBounds_Null_NoRestriction( ) { + HolidayRule rule = MakeFixedDateRule( 7, 4 ); + Assert.HasCount( 1, HolidayCalculator.ComputeDatesForYear( rule, 1900 ) ); + Assert.HasCount( 1, HolidayCalculator.ComputeDatesForYear( rule, 2100 ) ); + } + + // ── Leap Year & Edge Cases ───────────────────────────────────────────────── + + [TestMethod] + public void LeapYear_Feb29_LeapYear_Returns( ) { + HolidayRule rule = MakeFixedDateRule( 2, 29 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2024 ); + Assert.HasCount( 1, dates ); + Assert.AreEqual( new DateOnly( 2024, 2, 29 ), dates[0].Date ); + } + + [TestMethod] + public void LeapYear_Feb29_NonLeapYear_ReturnsEmpty( ) { + HolidayRule rule = MakeFixedDateRule( 2, 29 ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2025 ); + Assert.HasCount( 0, dates ); + } + + [TestMethod] + public void FixedDate_MissingMonth_ReturnsEmpty( ) { + HolidayRule rule = new( ) { + HolidayCalendarId = Guid.NewGuid( ), + Name = "No Month", + RuleType = HolidayRuleType.FixedDate, + Day = 1, + }; + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 0, dates ); + } + + [TestMethod] + public void FixedDate_MissingDay_ReturnsEmpty( ) { + HolidayRule rule = new( ) { + HolidayCalendarId = Guid.NewGuid( ), + Name = "No Day", + RuleType = HolidayRuleType.FixedDate, + Month = 1, + }; + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 0, dates ); + } + + [TestMethod] + public void NthWeekday_MissingDayOfWeek_ReturnsEmpty( ) { + HolidayRule rule = new( ) { + HolidayCalendarId = Guid.NewGuid( ), + Name = "No DayOfWeek", + RuleType = HolidayRuleType.NthWeekdayOfMonth, + Month = 1, + WeekNumber = 3, + }; + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2026 ); + Assert.HasCount( 0, dates ); + } + + // ── Time Window Inheritance ──────────────────────────────────────────────── + + [TestMethod] + public void FixedDate_WindowInherited( ) { + TimeOnly start = new( 9, 30 ); + TimeOnly end = new( 16, 0 ); + HolidayRule rule = MakeFixedDateRule( 7, 4, windowStart: start, windowEnd: end, windowTz: "America/New_York" ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForYear( rule, 2025 ); + + Assert.HasCount( 1, dates ); + Assert.AreEqual( start, dates[0].WindowStart ); + Assert.AreEqual( end, dates[0].WindowEnd ); + Assert.AreEqual( "America/New_York", dates[0].WindowTimeZoneId ); + } + + // ── Batch / Range Computation ────────────────────────────────────────────── + + [TestMethod] + public void ComputeAllDatesForYear_MultipleRules( ) { + HolidayCalendar cal = MakeCalendar( + MakeFixedDateRule( 1, 1 ), + MakeFixedDateRule( 7, 4 ), + MakeFixedDateRule( 12, 25 ) ); + + IReadOnlyList dates = HolidayCalculator.ComputeAllDatesForYear( cal, 2026 ); + Assert.HasCount( 3, dates ); + } + + [TestMethod] + public void ComputeDatesForRange_MultiYear( ) { + HolidayCalendar cal = MakeCalendar( + MakeFixedDateRule( 1, 1 ), + MakeFixedDateRule( 7, 4 ) ); + + IReadOnlyList dates = HolidayCalculator.ComputeDatesForRange( cal, 2025, 2027 ); + // 2 holidays × 3 years = 6 + Assert.HasCount( 6, dates ); + } + + // ── US Federal Holidays 2026 ─────────────────────────────────────────────── + + [TestMethod] + public void USFederalHolidays_2026_AllCorrect( ) { + ObservanceRule obs = ObservanceRule.SaturdayToFriday_SundayToMonday; + HolidayCalendar cal = MakeCalendar( + MakeFixedDateRule( 1, 1, obs ), // New Year's Day + MakeNthWeekdayRule( 1, DayOfWeek.Monday, 3 ), // MLK + MakeNthWeekdayRule( 2, DayOfWeek.Monday, 3 ), // Presidents' Day + MakeLastWeekdayRule( 5, DayOfWeek.Monday ), // Memorial Day + MakeFixedDateRule( 6, 19, obs ), // Juneteenth + MakeFixedDateRule( 7, 4, obs ), // Independence Day + MakeNthWeekdayRule( 9, DayOfWeek.Monday, 1 ), // Labor Day + MakeNthWeekdayRule( 10, DayOfWeek.Monday, 2 ), // Columbus Day + MakeFixedDateRule( 11, 11, obs ), // Veterans Day + MakeNthWeekdayRule( 11, DayOfWeek.Thursday, 4 ), // Thanksgiving + MakeFixedDateRule( 12, 25, obs ) ); // Christmas + + IReadOnlyList dates = HolidayCalculator.ComputeAllDatesForYear( cal, 2026 ); + Assert.HasCount( 11, dates ); + + DateOnly[] expected = [ + new( 2026, 1, 1 ), // New Year's Day (Thursday) + new( 2026, 1, 19 ), // MLK Day (Monday) + new( 2026, 2, 16 ), // Presidents' Day (Monday) + new( 2026, 5, 25 ), // Memorial Day (Monday) + new( 2026, 6, 19 ), // Juneteenth (Friday) + new( 2026, 7, 3 ), // Independence Day observed (Saturday → Friday) + new( 2026, 9, 7 ), // Labor Day (Monday) + new( 2026, 10, 12 ), // Columbus Day (Monday) + new( 2026, 11, 11 ), // Veterans Day (Wednesday) + new( 2026, 11, 26 ), // Thanksgiving (Thursday) + new( 2026, 12, 25 ), // Christmas (Friday) + ]; + + DateOnly[] actual = [.. dates.Select( d => d.Date ).OrderBy( d => d )]; + CollectionAssert.AreEqual( expected, actual ); + } + + [TestMethod] + public void USFederalHolidays_2027_AllCorrect( ) { + ObservanceRule obs = ObservanceRule.SaturdayToFriday_SundayToMonday; + HolidayCalendar cal = MakeCalendar( + MakeFixedDateRule( 1, 1, obs ), + MakeNthWeekdayRule( 1, DayOfWeek.Monday, 3 ), + MakeNthWeekdayRule( 2, DayOfWeek.Monday, 3 ), + MakeLastWeekdayRule( 5, DayOfWeek.Monday ), + MakeFixedDateRule( 6, 19, obs ), + MakeFixedDateRule( 7, 4, obs ), + MakeNthWeekdayRule( 9, DayOfWeek.Monday, 1 ), + MakeNthWeekdayRule( 10, DayOfWeek.Monday, 2 ), + MakeFixedDateRule( 11, 11, obs ), + MakeNthWeekdayRule( 11, DayOfWeek.Thursday, 4 ), + MakeFixedDateRule( 12, 25, obs ) ); + + IReadOnlyList dates = HolidayCalculator.ComputeAllDatesForYear( cal, 2027 ); + Assert.HasCount( 11, dates ); + + DateOnly[] expected = [ + new( 2027, 1, 1 ), // New Year's Day (Friday) + new( 2027, 1, 18 ), // MLK Day (Monday) + new( 2027, 2, 15 ), // Presidents' Day (Monday) + new( 2027, 5, 31 ), // Memorial Day (Monday) + new( 2027, 6, 18 ), // Juneteenth observed (Saturday → Friday) + new( 2027, 7, 5 ), // Independence Day observed (Sunday → Monday) + new( 2027, 9, 6 ), // Labor Day (Monday) + new( 2027, 10, 11 ), // Columbus Day (Monday) + new( 2027, 11, 11 ), // Veterans Day (Thursday) + new( 2027, 11, 25 ), // Thanksgiving (Thursday) + new( 2027, 12, 24 ), // Christmas observed (Saturday → Friday) + ]; + + DateOnly[] actual = [.. dates.Select( d => d.Date ).OrderBy( d => d )]; + CollectionAssert.AreEqual( expected, actual ); + } + + // ── Internal Helpers ─────────────────────────────────────────────────────── + + [TestMethod] + public void GetNthWeekdayOfMonth_FirstMonday_Jan2026( ) { + DateOnly? result = HolidayCalculator.GetNthWeekdayOfMonth( 2026, 1, DayOfWeek.Monday, 1 ); + Assert.AreEqual( new DateOnly( 2026, 1, 5 ), result ); + } + + [TestMethod] + public void GetLastWeekdayOfMonth_LastMonday_May2026( ) { + DateOnly result = HolidayCalculator.GetLastWeekdayOfMonth( 2026, 5, DayOfWeek.Monday ); + Assert.AreEqual( new DateOnly( 2026, 5, 25 ), result ); + } + + [TestMethod] + public void ApplyObservanceRule_None_ReturnsUnchanged( ) { + DateOnly saturday = new( 2026, 7, 4 ); + Assert.AreEqual( saturday, HolidayCalculator.ApplyObservanceRule( saturday, ObservanceRule.None ) ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayCalendarServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayCalendarServiceTests.cs new file mode 100644 index 0000000..b53d522 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayCalendarServiceTests.cs @@ -0,0 +1,483 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Api.Services; +using Werkr.Core.Communication; +using Werkr.Core.Scheduling; +using Werkr.Data; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Tests.Data.Unit.Scheduling; + +[TestClass] +public class HolidayCalendarServiceTests { + private SqliteConnection _connection = null!; + private SqliteWerkrDbContext _dbContext = null!; + private HolidayCalendarService _service = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + + HolidayDateService dateService = new( + _dbContext, + NullLogger.Instance ); + + // Build a minimal service provider for ScheduleInvalidationDispatcher (it needs IServiceScopeFactory). + // Register WerkrDbContext as *scoped* so each scope gets its own instance, avoiding + // concurrent-access errors when Task.Run inside DeleteAsync resolves a second DbContext. + // All instances share the same SQLite connection, so data is visible across contexts. + ServiceCollection services = new( ); + _ = services.AddScoped( _ => { + DbContextOptions scopeOpts = + new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + return new SqliteWerkrDbContext( scopeOpts ); + } ); + ServiceProvider sp = services.BuildServiceProvider( ); + + AgentConnectionManager connMgr = new( + sp.GetRequiredService( ), + NullLogger.Instance ); + + ScheduleInvalidationDispatcher dispatcher = new( + connMgr, + sp.GetRequiredService( ), + NullLogger.Instance ); + + _service = new HolidayCalendarService( + _dbContext, + dateService, + dispatcher, + NullLogger.Instance ); + } + + [TestCleanup] + public void TestCleanup( ) { + _dbContext?.Dispose( ); + _connection?.Dispose( ); + } + + #region Helpers + + private async Task SeedCalendarAsync( string name = "Test Calendar", bool isSystem = false, CancellationToken ct = default ) { + HolidayCalendar cal = new( ) { + Id = Guid.NewGuid( ), + Name = name, + Description = "Test description", + IsSystemCalendar = isSystem, + CreatedUtc = DateTime.UtcNow, + UpdatedUtc = DateTime.UtcNow, + }; + _ = _dbContext.HolidayCalendars.Add( cal ); + _ = await _dbContext.SaveChangesAsync( ct ); + return cal; + } + + private async Task SeedCalendarWithRulesAsync( string name = "Ruled Calendar", CancellationToken ct = default ) { + HolidayCalendar cal = await SeedCalendarAsync( name, ct: ct ); + _ = _dbContext.HolidayRules.Add( new HolidayRule { + HolidayCalendarId = cal.Id, + Name = "New Year", + RuleType = HolidayRuleType.FixedDate, + Month = 1, + Day = 1, + } ); + _ = _dbContext.HolidayRules.Add( new HolidayRule { + HolidayCalendarId = cal.Id, + Name = "Christmas", + RuleType = HolidayRuleType.FixedDate, + Month = 12, + Day = 25, + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + return cal; + } + + private async Task SeedScheduleAsync( CancellationToken ct = default ) { + DbSchedule sched = new( ) { + Id = Guid.NewGuid( ), + Name = "Test Schedule", + StopTaskAfterMinutes = 60, + Created = DateTime.UtcNow, + LastUpdated = DateTime.UtcNow, + }; + _ = _dbContext.Schedules.Add( sched ); + _ = await _dbContext.SaveChangesAsync( ct ); + return sched; + } + + #endregion + + // ── Calendar CRUD ────────────────────────────────────────────────────────── + + [TestMethod] + public async Task CreateAsync_SetsIsSystemFalse( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = new( ) { + Name = "Custom", + Description = "Custom calendar", + IsSystemCalendar = true, // attempting to set true + }; + + HolidayCalendar result = await _service.CreateAsync( cal, ct ); + + Assert.IsFalse( result.IsSystemCalendar ); + Assert.AreNotEqual( default, result.CreatedUtc ); + } + + [TestMethod] + public async Task UpdateAsync_UpdatesNameAndDescription( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( ct: ct ); + HolidayCalendar updated = new( ) { Name = "Updated Name", Description = "Updated Desc" }; + + HolidayCalendar result = await _service.UpdateAsync( cal.Id, updated, ct ); + + Assert.AreEqual( "Updated Name", result.Name ); + Assert.AreEqual( "Updated Desc", result.Description ); + } + + [TestMethod] + public async Task DeleteAsync_RemovesCalendar( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( ct: ct ); + + await _service.DeleteAsync( cal.Id, ct ); + + HolidayCalendar? found = await _dbContext.HolidayCalendars.FindAsync( [cal.Id], ct ); + Assert.IsNull( found ); + } + + [TestMethod] + public async Task GetByIdAsync_ReturnsWithRulesAndDates( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct: ct ); + + HolidayCalendar? found = await _service.GetByIdAsync( cal.Id, ct ); + + Assert.IsNotNull( found ); + Assert.HasCount( 2, found.Rules ); + } + + [TestMethod] + public async Task GetAllAsync_ReturnsList( ) { + CancellationToken ct = TestContext.CancellationToken; + _ = await SeedCalendarAsync( "Cal A", ct: ct ); + _ = await SeedCalendarAsync( "Cal B", ct: ct ); + + IReadOnlyList all = await _service.GetAllAsync( ct ); + + Assert.HasCount( 2, all ); + } + + // ── System Calendar Protection ───────────────────────────────────────────── + + [TestMethod] + public async Task UpdateAsync_SystemCalendar_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( "System", true, ct ); + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.UpdateAsync( cal.Id, new HolidayCalendar { Name = "X" }, ct ) ); + } + + [TestMethod] + public async Task DeleteAsync_SystemCalendar_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( "System", true, ct ); + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.DeleteAsync( cal.Id, ct ) ); + } + + // ── Clone ────────────────────────────────────────────────────────────────── + + [TestMethod] + public async Task CloneAsync_CopiesRulesAsNonSystem( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar source = await SeedCalendarWithRulesAsync( "Source", ct: ct ); + + HolidayCalendar clone = await _service.CloneAsync( source.Id, "Clone of Source", ct ); + + Assert.AreNotEqual( source.Id, clone.Id ); + Assert.AreEqual( "Clone of Source", clone.Name ); + Assert.IsFalse( clone.IsSystemCalendar ); + + // Verify rules were cloned + HolidayCalendar? loaded = await _service.GetByIdAsync( clone.Id, ct ); + Assert.IsNotNull( loaded ); + Assert.HasCount( 2, loaded.Rules ); + } + + [TestMethod] + public async Task CloneAsync_SystemCalendar_CanBeCloned( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar system = await SeedCalendarAsync( "US Federal", true, ct ); + _ = _dbContext.HolidayRules.Add( new HolidayRule { + HolidayCalendarId = system.Id, + Name = "July 4", + RuleType = HolidayRuleType.FixedDate, + Month = 7, + Day = 4, + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + HolidayCalendar clone = await _service.CloneAsync( system.Id, "My Federal Holidays", ct ); + + Assert.IsFalse( clone.IsSystemCalendar ); + HolidayCalendar? loaded = await _service.GetByIdAsync( clone.Id, ct ); + Assert.IsNotNull( loaded ); + Assert.HasCount( 1, loaded.Rules ); + } + + // ── Rule Operations ──────────────────────────────────────────────────────── + + [TestMethod] + public async Task AddRuleAsync_AddsAndInvalidatesCache( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( ct: ct ); + + HolidayRule rule = new( ) { + Name = "July 4", + RuleType = HolidayRuleType.FixedDate, + Month = 7, + Day = 4, + }; + + HolidayRule added = await _service.AddRuleAsync( cal.Id, rule, ct ); + + Assert.AreNotEqual( 0, added.Id ); + Assert.AreEqual( cal.Id, added.HolidayCalendarId ); + } + + [TestMethod] + public async Task AddRuleAsync_SystemCalendar_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( "System", true, ct ); + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.AddRuleAsync( cal.Id, new HolidayRule { + Name = "Test", + RuleType = HolidayRuleType.FixedDate, + Month = 1, + Day = 1, + }, ct ) ); + } + + [TestMethod] + public async Task AddRuleAsync_InvalidRule_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( ct: ct ); + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.AddRuleAsync( cal.Id, new HolidayRule { + Name = "", // empty name fails validation + RuleType = HolidayRuleType.FixedDate, + Month = 1, + Day = 1, + }, ct ) ); + } + + [TestMethod] + public async Task UpdateRuleAsync_UpdatesFields( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct: ct ); + List rules = await _dbContext.HolidayRules + .Where( r => r.HolidayCalendarId == cal.Id ) + .ToListAsync( ct ); + + HolidayRule ruleToUpdate = rules[0]; + HolidayRule updated = new( ) { + Name = "Updated Name", + RuleType = ruleToUpdate.RuleType, + Month = ruleToUpdate.Month, + Day = ruleToUpdate.Day, + }; + + HolidayRule result = await _service.UpdateRuleAsync( cal.Id, ruleToUpdate.Id, updated, ct ); + Assert.AreEqual( "Updated Name", result.Name ); + } + + [TestMethod] + public async Task RemoveRuleAsync_DeletesRule( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct: ct ); + List rules = await _dbContext.HolidayRules + .Where( r => r.HolidayCalendarId == cal.Id ) + .ToListAsync( ct ); + + await _service.RemoveRuleAsync( cal.Id, rules[0].Id, ct ); + + int remaining = await _dbContext.HolidayRules + .CountAsync( r => r.HolidayCalendarId == cal.Id, ct ); + Assert.AreEqual( 1, remaining ); + } + + // ── Manual Date Operations ───────────────────────────────────────────────── + + [TestMethod] + public async Task AddManualDateAsync_AddsDate( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( ct: ct ); + + HolidayDate date = new( ) { + Date = new DateOnly( 2026, 3, 15 ), + Name = "Company Holiday", + Year = 2026, + }; + + HolidayDate added = await _service.AddManualDateAsync( cal.Id, date, ct ); + + Assert.AreNotEqual( 0, added.Id ); + Assert.IsNull( added.HolidayRuleId ); + Assert.AreEqual( cal.Id, added.HolidayCalendarId ); + } + + [TestMethod] + public async Task RemoveManualDateAsync_RemovesDate( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( ct: ct ); + HolidayDate date = new( ) { + HolidayCalendarId = cal.Id, + Date = new DateOnly( 2026, 3, 15 ), + Name = "Company Holiday", + Year = 2026, + }; + _ = _dbContext.HolidayDates.Add( date ); + _ = await _dbContext.SaveChangesAsync( ct ); + + await _service.RemoveManualDateAsync( cal.Id, date.Id, ct ); + + int count = await _dbContext.HolidayDates.CountAsync( d => d.HolidayCalendarId == cal.Id, ct ); + Assert.AreEqual( 0, count ); + } + + // ── Attach / Detach ──────────────────────────────────────────────────────── + + [TestMethod] + public async Task AttachToScheduleAsync_CreatesLink( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( ct: ct ); + DbSchedule sched = await SeedScheduleAsync( ct ); + + ScheduleHolidayCalendar link = await _service.AttachToScheduleAsync( + sched.Id, cal.Id, HolidayCalendarMode.Blocklist, ct ); + + Assert.AreEqual( sched.Id, link.ScheduleId ); + Assert.AreEqual( cal.Id, link.HolidayCalendarId ); + Assert.AreEqual( HolidayCalendarMode.Blocklist, link.Mode ); + } + + [TestMethod] + public async Task DetachFromScheduleAsync_RemovesLink( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( ct: ct ); + DbSchedule sched = await SeedScheduleAsync( ct ); + + _ = await _service.AttachToScheduleAsync( + sched.Id, cal.Id, HolidayCalendarMode.Blocklist, ct ); + + await _service.DetachFromScheduleAsync( sched.Id, ct ); + + ScheduleHolidayCalendar? link = await _dbContext.ScheduleHolidayCalendars + .FirstOrDefaultAsync( l => l.ScheduleId == sched.Id, ct ); + Assert.IsNull( link ); + } + + [TestMethod] + public async Task DetachFromScheduleAsync_NoLink_NoException( ) { + CancellationToken ct = TestContext.CancellationToken; + DbSchedule sched = await SeedScheduleAsync( ct ); + + // Should not throw + await _service.DetachFromScheduleAsync( sched.Id, ct ); + } + + [TestMethod] + public async Task GetScheduleCalendarAsync_ReturnsAttachedCalendar( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( ct: ct ); + DbSchedule sched = await SeedScheduleAsync( ct ); + + _ = await _service.AttachToScheduleAsync( + sched.Id, cal.Id, HolidayCalendarMode.Allowlist, ct ); + + ScheduleHolidayCalendar? result = await _service.GetScheduleCalendarAsync( sched.Id, ct ); + + Assert.IsNotNull( result ); + Assert.AreEqual( HolidayCalendarMode.Allowlist, result.Mode ); + Assert.IsNotNull( result.Calendar ); + Assert.AreEqual( cal.Name, result.Calendar.Name ); + } + + [TestMethod] + public async Task GetScheduleCalendarAsync_NoAttachment_ReturnsNull( ) { + CancellationToken ct = TestContext.CancellationToken; + DbSchedule sched = await SeedScheduleAsync( ct ); + ScheduleHolidayCalendar? result = await _service.GetScheduleCalendarAsync( sched.Id, ct ); + Assert.IsNull( result ); + } + + // ── Cascade Delete ───────────────────────────────────────────────────────── + + [TestMethod] + public async Task DeleteAsync_DetachesFromSchedules( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarAsync( ct: ct ); + DbSchedule sched = await SeedScheduleAsync( ct ); + + _ = await _service.AttachToScheduleAsync( + sched.Id, cal.Id, HolidayCalendarMode.Blocklist, ct ); + + await _service.DeleteAsync( cal.Id, ct ); + + ScheduleHolidayCalendar? link = await _dbContext.ScheduleHolidayCalendars + .FirstOrDefaultAsync( l => l.ScheduleId == sched.Id, ct ); + Assert.IsNull( link ); + } + + // ── Preview ──────────────────────────────────────────────────────────────── + + [TestMethod] + public async Task PreviewDatesAsync_IncludesRulesAndManual( ) { + CancellationToken ct = TestContext.CancellationToken; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct: ct ); + _ = _dbContext.HolidayDates.Add( new HolidayDate { + HolidayCalendarId = cal.Id, + Date = new DateOnly( 2026, 6, 19 ), + Name = "Juneteenth", + Year = 2026, + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + IReadOnlyList preview = await _service.PreviewDatesAsync( cal.Id, 2026, 2026, ct ); + + // 2 rules (Jan 1, Dec 25) + 1 manual = 3 + Assert.HasCount( 3, preview ); + } + + [TestMethod] + public void PreviewRuleAsync_ReturnsComputedDates( ) { + HolidayRule rule = new( ) { + Name = "July 4", + RuleType = HolidayRuleType.FixedDate, + Month = 7, + Day = 4, + }; + + IReadOnlyList preview = HolidayCalendarService.PreviewRuleAsync( rule, 2025, 2027 ); + + Assert.HasCount( 3, preview ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayDateServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayDateServiceTests.cs new file mode 100644 index 0000000..a1be197 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayDateServiceTests.cs @@ -0,0 +1,287 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Core.Scheduling; +using Werkr.Data; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Tests.Data.Unit.Scheduling; + +[TestClass] +public class HolidayDateServiceTests { + private SqliteConnection _connection = null!; + private SqliteWerkrDbContext _dbContext = null!; + private HolidayDateService _service = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + + _service = new HolidayDateService( _dbContext, NullLogger.Instance ); + } + + [TestCleanup] + public void TestCleanup( ) { + _dbContext?.Dispose( ); + _connection?.Dispose( ); + } + + #region Helpers + + private async Task SeedCalendarWithRulesAsync( CancellationToken ct ) { + HolidayCalendar cal = new( ) { + Id = Guid.NewGuid( ), + Name = "Test Calendar", + CreatedUtc = DateTime.UtcNow, + UpdatedUtc = DateTime.UtcNow, + }; + _ = _dbContext.HolidayCalendars.Add( cal ); + + _ = _dbContext.HolidayRules.Add( new HolidayRule { + HolidayCalendarId = cal.Id, + Name = "New Year's Day", + RuleType = HolidayRuleType.FixedDate, + Month = 1, + Day = 1, + } ); + _ = _dbContext.HolidayRules.Add( new HolidayRule { + HolidayCalendarId = cal.Id, + Name = "Independence Day", + RuleType = HolidayRuleType.FixedDate, + Month = 7, + Day = 4, + } ); + _ = _dbContext.HolidayRules.Add( new HolidayRule { + HolidayCalendarId = cal.Id, + Name = "Christmas", + RuleType = HolidayRuleType.FixedDate, + Month = 12, + Day = 25, + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + return cal; + } + + private async Task SeedEmptyCalendarAsync( CancellationToken ct ) { + HolidayCalendar cal = new( ) { + Id = Guid.NewGuid( ), + Name = "Empty Calendar", + CreatedUtc = DateTime.UtcNow, + UpdatedUtc = DateTime.UtcNow, + }; + _ = _dbContext.HolidayCalendars.Add( cal ); + _ = await _dbContext.SaveChangesAsync( ct ); + return cal; + } + + #endregion + + // ── Materialization ──────────────────────────────────────────────────────── + + [TestMethod] + public async Task MaterializeDates_CreatesDatesFromRules( ) { + CancellationToken ct = TestContext.CancellationTokenSource.Token; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); + + await _service.MaterializeDatesAsync( cal.Id, 2026, 2026, ct ); + + List dates = await _dbContext.HolidayDates + .Where( d => d.HolidayCalendarId == cal.Id ) + .ToListAsync( ct ); + + Assert.HasCount( 3, dates ); + Assert.IsTrue( dates.All( d => d.HolidayRuleId != null ) ); + } + + [TestMethod] + public async Task MaterializeDates_MultiYear_CreatesAll( ) { + CancellationToken ct = TestContext.CancellationTokenSource.Token; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); + + await _service.MaterializeDatesAsync( cal.Id, 2025, 2027, ct ); + + int count = await _dbContext.HolidayDates + .CountAsync( d => d.HolidayCalendarId == cal.Id, ct ); + + // 3 rules × 3 years = 9 + Assert.AreEqual( 9, count ); + } + + [TestMethod] + public async Task MaterializeDates_Idempotent_NoDoubleInsert( ) { + CancellationToken ct = TestContext.CancellationTokenSource.Token; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); + + await _service.MaterializeDatesAsync( cal.Id, 2026, 2026, ct ); + await _service.MaterializeDatesAsync( cal.Id, 2026, 2026, ct ); + + int count = await _dbContext.HolidayDates + .CountAsync( d => d.HolidayCalendarId == cal.Id, ct ); + + Assert.AreEqual( 3, count ); + } + + [TestMethod] + public async Task MaterializeDates_EmptyCalendar_NoExceptions( ) { + CancellationToken ct = TestContext.CancellationTokenSource.Token; + HolidayCalendar cal = await SeedEmptyCalendarAsync( ct ); + + await _service.MaterializeDatesAsync( cal.Id, 2026, 2026, ct ); + + int count = await _dbContext.HolidayDates + .CountAsync( d => d.HolidayCalendarId == cal.Id, ct ); + + Assert.AreEqual( 0, count ); + } + + [TestMethod] + public async Task MaterializeDates_NonexistentCalendar_NoExceptions( ) { + CancellationToken ct = TestContext.CancellationTokenSource.Token; + await _service.MaterializeDatesAsync( Guid.NewGuid( ), 2026, 2026, ct ); + // Should not throw + } + + // ── Invalidation ─────────────────────────────────────────────────────────── + + [TestMethod] + public async Task InvalidateCache_RemovesRuleGenerated_PreservesManual( ) { + CancellationToken ct = TestContext.CancellationTokenSource.Token; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); + + // Materialize rule-generated dates + await _service.MaterializeDatesAsync( cal.Id, 2026, 2026, ct ); + + // Add a manual date + _ = _dbContext.HolidayDates.Add( new HolidayDate { + HolidayCalendarId = cal.Id, + Date = new DateOnly( 2026, 3, 15 ), + Name = "Manual Holiday", + Year = 2026, + HolidayRuleId = null, + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + int totalBefore = await _dbContext.HolidayDates.CountAsync( d => d.HolidayCalendarId == cal.Id, ct ); + Assert.AreEqual( 4, totalBefore ); // 3 rule + 1 manual + + await _service.InvalidateCacheAsync( cal.Id, ct ); + + List remaining = await _dbContext.HolidayDates + .Where( d => d.HolidayCalendarId == cal.Id ) + .ToListAsync( ct ); + + Assert.HasCount( 1, remaining ); + Assert.IsNull( remaining[0].HolidayRuleId ); // manual entry preserved + Assert.AreEqual( "Manual Holiday", remaining[0].Name ); + } + + // ── Auto-Materialization ─────────────────────────────────────────────────── + + [TestMethod] + public async Task GetDatesForRange_AutoMaterializesWhenMissing( ) { + CancellationToken ct = TestContext.CancellationTokenSource.Token; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); + + // No dates materialized yet + IReadOnlyList dates = await _service.GetDatesForRangeAsync( + cal.Id, new DateOnly( 2026, 1, 1 ), new DateOnly( 2026, 12, 31 ), ct ); + + Assert.HasCount( 3, dates ); + } + + [TestMethod] + public async Task GetDatesForRange_IncludesManualDates( ) { + CancellationToken ct = TestContext.CancellationTokenSource.Token; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); + + // Add a manual date + _ = _dbContext.HolidayDates.Add( new HolidayDate { + HolidayCalendarId = cal.Id, + Date = new DateOnly( 2026, 6, 19 ), + Name = "Juneteenth (manual)", + Year = 2026, + HolidayRuleId = null, + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + IReadOnlyList dates = await _service.GetDatesForRangeAsync( + cal.Id, new DateOnly( 2026, 1, 1 ), new DateOnly( 2026, 12, 31 ), ct ); + + // 3 from rules + 1 manual = 4 + Assert.HasCount( 4, dates ); + } + + // ── Merge H17 ────────────────────────────────────────────────────────────── + + [TestMethod] + public async Task MaterializeDates_MergesOntoManualEntry( ) { + CancellationToken ct = TestContext.CancellationTokenSource.Token; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); + + // Pre-insert a manual entry matching a rule's output date + _ = _dbContext.HolidayDates.Add( new HolidayDate { + HolidayCalendarId = cal.Id, + Date = new DateOnly( 2026, 1, 1 ), + Name = "Manual New Year", + Year = 2026, + HolidayRuleId = null, // manual + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + await _service.MaterializeDatesAsync( cal.Id, 2026, 2026, ct ); + + List allDates = await _dbContext.HolidayDates + .Where( d => d.HolidayCalendarId == cal.Id && d.Year == 2026 ) + .ToListAsync( ct ); + + // Should merge: 1 merged (Jan 1) + 2 new (Jul 4, Dec 25) = 3 + Assert.HasCount( 3, allDates ); + + // The Jan 1 entry should now have a HolidayRuleId (merged from rule) + HolidayDate jan1 = allDates.First( d => d.Date == new DateOnly( 2026, 1, 1 ) ); + Assert.IsNotNull( jan1.HolidayRuleId ); + } + + // ── EnsureMaterialized ───────────────────────────────────────────────────── + + [TestMethod] + public async Task EnsureMaterialized_OnlyRunsOnce( ) { + CancellationToken ct = TestContext.CancellationTokenSource.Token; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); + + await _service.EnsureMaterializedAsync( cal.Id, 2026, ct ); + int countAfterFirst = await _dbContext.HolidayDates.CountAsync( d => d.HolidayCalendarId == cal.Id, ct ); + + await _service.EnsureMaterializedAsync( cal.Id, 2026, ct ); + int countAfterSecond = await _dbContext.HolidayDates.CountAsync( d => d.HolidayCalendarId == cal.Id, ct ); + + Assert.AreEqual( countAfterFirst, countAfterSecond ); + } + + // ── Empty Range ──────────────────────────────────────────────────────────── + + [TestMethod] + public async Task GetDatesForRange_EmptyDateRange_ReturnsEmpty( ) { + CancellationToken ct = TestContext.CancellationTokenSource.Token; + HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); + await _service.MaterializeDatesAsync( cal.Id, 2026, 2026, ct ); + + // Query a range that contains no holidays (March–April has none of our 3 holidays) + IReadOnlyList dates = await _service.GetDatesForRangeAsync( + cal.Id, new DateOnly( 2026, 3, 1 ), new DateOnly( 2026, 4, 30 ), ct ); + + Assert.HasCount( 0, dates ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayFilterTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayFilterTests.cs new file mode 100644 index 0000000..1b2c869 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayFilterTests.cs @@ -0,0 +1,310 @@ +using Werkr.Core.Scheduling; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Models; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Tests.Data.Unit.Scheduling; + +[TestClass] +public class HolidayFilterTests { + + #region Helpers + + private static Schedule MakeDailySchedule( DateOnly startDate, TimeOnly startTime, string tzId = "UTC" ) => new( ) { + DbSchedule = new DbSchedule { Name = "Test Daily", StopTaskAfterMinutes = 30 }, + StartDateTime = new StartDateTimeInfo { + Date = startDate, + Time = startTime, + TimeZone = TimeZoneInfo.FindSystemTimeZoneById( tzId ), + }, + DailyRecurrence = new DailyRecurrence { DayInterval = 1 }, + }; + + private static HolidayDate MakeFullDayHoliday( DateOnly date, string name = "Holiday" ) => new( ) { + Date = date, + Name = name, + Year = date.Year, + }; + + private static HolidayDate MakeTimeWindowHoliday( + DateOnly date, TimeOnly windowStart, TimeOnly windowEnd, + string tzId = "America/New_York", string name = "Window Holiday" ) => new( ) { + Date = date, + Name = name, + Year = date.Year, + WindowStart = windowStart, + WindowEnd = windowEnd, + WindowTimeZoneId = tzId, + }; + + #endregion + + // ── Blocklist Mode ───────────────────────────────────────────────────────── + + [TestMethod] + public void Blocklist_SuppressesMatchingOccurrences( ) { + Schedule schedule = MakeDailySchedule( new DateOnly( 2026, 7, 1 ), new TimeOnly( 9, 0 ) ); + DateTime endOfWindow = new( 2026, 7, 10, 0, 0, 0, DateTimeKind.Utc ); + + List holidays = [ + MakeFullDayHoliday( new DateOnly( 2026, 7, 4 ), "Independence Day" ), + ]; + + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, holidays, HolidayCalendarMode.Blocklist ); + + // July 1-9 is 9 days, minus July 4 = 8 + Assert.HasCount( 8, result.Occurrences ); + Assert.HasCount( 1, result.Suppressed ); + Assert.AreEqual( "Independence Day", result.Suppressed[0].HolidayName ); + } + + [TestMethod] + public void Blocklist_NoMatch_KeepsAll( ) { + Schedule schedule = MakeDailySchedule( new DateOnly( 2026, 3, 1 ), new TimeOnly( 9, 0 ) ); + DateTime endOfWindow = new( 2026, 3, 5, 0, 0, 0, DateTimeKind.Utc ); + + List holidays = [ + MakeFullDayHoliday( new DateOnly( 2026, 7, 4 ) ), + ]; + + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, holidays, HolidayCalendarMode.Blocklist ); + + Assert.HasCount( 4, result.Occurrences ); + Assert.HasCount( 0, result.Suppressed ); + } + + [TestMethod] + public void Blocklist_MultipleHolidays( ) { + Schedule schedule = MakeDailySchedule( new DateOnly( 2026, 12, 23 ), new TimeOnly( 9, 0 ) ); + DateTime endOfWindow = new( 2026, 12, 28, 0, 0, 0, DateTimeKind.Utc ); + + List holidays = [ + MakeFullDayHoliday( new DateOnly( 2026, 12, 25 ), "Christmas" ), + MakeFullDayHoliday( new DateOnly( 2026, 12, 26 ), "Boxing Day" ), + ]; + + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, holidays, HolidayCalendarMode.Blocklist ); + + // Dec 23-27 = 5 days, minus 2 holidays = 3 + Assert.HasCount( 3, result.Occurrences ); + Assert.HasCount( 2, result.Suppressed ); + } + + // ── Allowlist Mode ───────────────────────────────────────────────────────── + + [TestMethod] + public void Allowlist_KeepsOnlyMatches( ) { + Schedule schedule = MakeDailySchedule( new DateOnly( 2026, 7, 1 ), new TimeOnly( 9, 0 ) ); + DateTime endOfWindow = new( 2026, 7, 10, 0, 0, 0, DateTimeKind.Utc ); + + List holidays = [ + MakeFullDayHoliday( new DateOnly( 2026, 7, 4 ), "Independence Day" ), + ]; + + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, holidays, HolidayCalendarMode.Allowlist ); + + // Only July 4 is allowed + Assert.HasCount( 1, result.Occurrences ); + Assert.HasCount( 8, result.Suppressed ); + } + + [TestMethod] + public void Allowlist_ThreeDates_KeepsThree( ) { + Schedule schedule = MakeDailySchedule( new DateOnly( 2026, 1, 1 ), new TimeOnly( 9, 0 ) ); + DateTime endOfWindow = new( 2026, 1, 15, 0, 0, 0, DateTimeKind.Utc ); + + List holidays = [ + MakeFullDayHoliday( new DateOnly( 2026, 1, 1 ), "New Year" ), + MakeFullDayHoliday( new DateOnly( 2026, 1, 5 ), "Custom Holiday A" ), + MakeFullDayHoliday( new DateOnly( 2026, 1, 10 ), "Custom Holiday B" ), + ]; + + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, holidays, HolidayCalendarMode.Allowlist ); + + Assert.HasCount( 3, result.Occurrences ); + } + + // ── Full-Day Holiday Matching ────────────────────────────────────────────── + + [TestMethod] + public void FullDay_ExactDateMatch( ) { + DateTime utcOccurrence = new( 2026, 7, 4, 14, 0, 0, DateTimeKind.Utc ); + HolidayDate holiday = MakeFullDayHoliday( new DateOnly( 2026, 7, 4 ) ); + Assert.IsTrue( ScheduleCalculator.IsOccurrenceOnHoliday( utcOccurrence, holiday ) ); + } + + [TestMethod] + public void FullDay_DifferentDate_NoMatch( ) { + DateTime utcOccurrence = new( 2026, 7, 5, 14, 0, 0, DateTimeKind.Utc ); + HolidayDate holiday = MakeFullDayHoliday( new DateOnly( 2026, 7, 4 ) ); + Assert.IsFalse( ScheduleCalculator.IsOccurrenceOnHoliday( utcOccurrence, holiday ) ); + } + + // ── Time-Window Holiday Matching ─────────────────────────────────────────── + + [TestMethod] + public void TimeWindow_InsideWindow_Matches( ) { + // Holiday: Jul 4 2026, 9:30 AM – 4:00 PM America/New_York + HolidayDate holiday = MakeTimeWindowHoliday( + new DateOnly( 2026, 7, 3 ), + new TimeOnly( 9, 30 ), new TimeOnly( 16, 0 ), "America/New_York" ); + + // 2:00 PM EDT = 18:00 UTC (EDT is UTC-4) + DateTime utcOccurrence = new( 2026, 7, 3, 18, 0, 0, DateTimeKind.Utc ); + Assert.IsTrue( ScheduleCalculator.IsOccurrenceOnHoliday( utcOccurrence, holiday ) ); + } + + [TestMethod] + public void TimeWindow_OutsideWindow_NoMatch( ) { + // Holiday: Jul 3 2026, 9:30 AM – 4:00 PM America/New_York + HolidayDate holiday = MakeTimeWindowHoliday( + new DateOnly( 2026, 7, 3 ), + new TimeOnly( 9, 30 ), new TimeOnly( 16, 0 ), "America/New_York" ); + + // 6:00 PM EDT = 22:00 UTC — outside window + DateTime utcOccurrence = new( 2026, 7, 3, 22, 0, 0, DateTimeKind.Utc ); + Assert.IsFalse( ScheduleCalculator.IsOccurrenceOnHoliday( utcOccurrence, holiday ) ); + } + + [TestMethod] + public void TimeWindow_Blocklist_BlocksInsidePreservesOutside( ) { + Schedule schedule = MakeDailySchedule( new DateOnly( 2026, 7, 3 ), new TimeOnly( 14, 0 ) ); + DateTime endOfWindow = new( 2026, 7, 5, 0, 0, 0, DateTimeKind.Utc ); + + // Holiday: Jul 3, 9:30 AM – 4:00 PM UTC + List holidays = [ + MakeTimeWindowHoliday( + new DateOnly( 2026, 7, 3 ), + new TimeOnly( 9, 30 ), new TimeOnly( 16, 0 ), "UTC", "Market Closure" ), + ]; + + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, holidays, HolidayCalendarMode.Blocklist ); + + // Jul 3 at 14:00 UTC is within window → blocked; Jul 4 at 14:00 UTC is not Jul 3 → kept + Assert.HasCount( 1, result.Occurrences ); + Assert.HasCount( 1, result.Suppressed ); + Assert.AreEqual( "Market Closure", result.Suppressed[0].HolidayName ); + } + + // ── Null / Empty Calendar Passthrough ────────────────────────────────────── + + [TestMethod] + public void NullHolidayDates_KeepsAllOccurrences( ) { + Schedule schedule = MakeDailySchedule( new DateOnly( 2026, 7, 1 ), new TimeOnly( 9, 0 ) ); + DateTime endOfWindow = new( 2026, 7, 5, 0, 0, 0, DateTimeKind.Utc ); + + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, null, HolidayCalendarMode.Blocklist ); + + Assert.HasCount( 4, result.Occurrences ); + Assert.HasCount( 0, result.Suppressed ); + } + + [TestMethod] + public void EmptyHolidayDates_KeepsAllOccurrences( ) { + Schedule schedule = MakeDailySchedule( new DateOnly( 2026, 7, 1 ), new TimeOnly( 9, 0 ) ); + DateTime endOfWindow = new( 2026, 7, 5, 0, 0, 0, DateTimeKind.Utc ); + + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, [], HolidayCalendarMode.Blocklist ); + + Assert.HasCount( 4, result.Occurrences ); + Assert.HasCount( 0, result.Suppressed ); + } + + [TestMethod] + public void NullMode_KeepsAllOccurrences( ) { + Schedule schedule = MakeDailySchedule( new DateOnly( 2026, 7, 1 ), new TimeOnly( 9, 0 ) ); + DateTime endOfWindow = new( 2026, 7, 5, 0, 0, 0, DateTimeKind.Utc ); + + List holidays = [MakeFullDayHoliday( new DateOnly( 2026, 7, 4 ) )]; + + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, holidays, null ); + + Assert.HasCount( 4, result.Occurrences ); + Assert.HasCount( 0, result.Suppressed ); + } + + // ── Suppressed Tracking ──────────────────────────────────────────────────── + + [TestMethod] + public void Suppressed_ContainsCorrectDetails( ) { + Schedule schedule = MakeDailySchedule( new DateOnly( 2026, 7, 3 ), new TimeOnly( 12, 0 ) ); + DateTime endOfWindow = new( 2026, 7, 6, 0, 0, 0, DateTimeKind.Utc ); + + List holidays = [ + MakeFullDayHoliday( new DateOnly( 2026, 7, 4 ), "Independence Day" ), + ]; + + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, holidays, HolidayCalendarMode.Blocklist ); + + Assert.HasCount( 1, result.Suppressed ); + + SuppressedOccurrence sup = result.Suppressed[0]; + Assert.AreEqual( "Independence Day", sup.HolidayName ); + Assert.Contains( "Independence Day", sup.Reason ); + Assert.AreEqual( new DateTime( 2026, 7, 4, 12, 0, 0, DateTimeKind.Utc ), sup.UtcTime ); + } + + [TestMethod] + public void Allowlist_SuppressedReason_ContainsNotAllowed( ) { + Schedule schedule = MakeDailySchedule( new DateOnly( 2026, 7, 1 ), new TimeOnly( 9, 0 ) ); + DateTime endOfWindow = new( 2026, 7, 3, 0, 0, 0, DateTimeKind.Utc ); + + List holidays = [ + MakeFullDayHoliday( new DateOnly( 2026, 7, 4 ) ), // not in range + ]; + + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, holidays, HolidayCalendarMode.Allowlist ); + + Assert.HasCount( 0, result.Occurrences ); + Assert.IsNotEmpty( result.Suppressed ); + Assert.IsTrue( result.Suppressed.All( s => s.Reason.Contains( "allowed", StringComparison.OrdinalIgnoreCase ) ) ); + } + + // ── Holiday-Aware Full-Year Blocklist ────────────────────────────────────── + + [TestMethod] + public void FullYear_DailySchedule_USFederal_CorrectOccurrences( ) { + Schedule schedule = MakeDailySchedule( new DateOnly( 2026, 1, 1 ), new TimeOnly( 9, 0 ) ); + DateTime endOfWindow = new( 2027, 1, 1, 0, 0, 0, DateTimeKind.Utc ); + + // Build 11 US Federal holidays for 2026 + ObservanceRule obs = ObservanceRule.SaturdayToFriday_SundayToMonday; + HolidayCalendar cal = new( ) { + Id = Guid.NewGuid( ), + Name = "US Federal", + Rules = [ + new HolidayRule { Name = "New Year", RuleType = HolidayRuleType.FixedDate, Month = 1, Day = 1, ObservanceRule = obs }, + new HolidayRule { Name = "MLK", RuleType = HolidayRuleType.NthWeekdayOfMonth, Month = 1, DayOfWeek = DayOfWeek.Monday, WeekNumber = 3 }, + new HolidayRule { Name = "PresDay", RuleType = HolidayRuleType.NthWeekdayOfMonth, Month = 2, DayOfWeek = DayOfWeek.Monday, WeekNumber = 3 }, + new HolidayRule { Name = "MemDay", RuleType = HolidayRuleType.LastWeekdayOfMonth, Month = 5, DayOfWeek = DayOfWeek.Monday }, + new HolidayRule { Name = "Juneteenth", RuleType = HolidayRuleType.FixedDate, Month = 6, Day = 19, ObservanceRule = obs }, + new HolidayRule { Name = "IndDay", RuleType = HolidayRuleType.FixedDate, Month = 7, Day = 4, ObservanceRule = obs }, + new HolidayRule { Name = "LaborDay", RuleType = HolidayRuleType.NthWeekdayOfMonth, Month = 9, DayOfWeek = DayOfWeek.Monday, WeekNumber = 1 }, + new HolidayRule { Name = "ColDay", RuleType = HolidayRuleType.NthWeekdayOfMonth, Month = 10, DayOfWeek = DayOfWeek.Monday, WeekNumber = 2 }, + new HolidayRule { Name = "VetDay", RuleType = HolidayRuleType.FixedDate, Month = 11, Day = 11, ObservanceRule = obs }, + new HolidayRule { Name = "Tgiving", RuleType = HolidayRuleType.NthWeekdayOfMonth, Month = 11, DayOfWeek = DayOfWeek.Thursday, WeekNumber = 4 }, + new HolidayRule { Name = "Xmas", RuleType = HolidayRuleType.FixedDate, Month = 12, Day = 25, ObservanceRule = obs }, + ], + }; + + IReadOnlyList holidays = HolidayCalculator.ComputeAllDatesForYear( cal, 2026 ); + + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, holidays, HolidayCalendarMode.Blocklist ); + + // 365 days minus 11 holidays = 354 + Assert.HasCount( 354, result.Occurrences ); + Assert.HasCount( 11, result.Suppressed ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayRuleValidatorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayRuleValidatorTests.cs new file mode 100644 index 0000000..91a9929 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayRuleValidatorTests.cs @@ -0,0 +1,305 @@ +using System.ComponentModel.DataAnnotations; + +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Validation; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Tests.Data.Unit.Scheduling; + +[TestClass] +public class HolidayRuleValidatorTests { + + #region Helpers + + private static HolidayRule MakeValidFixedDate( ) => new( ) { + Name = "Test Fixed", + RuleType = HolidayRuleType.FixedDate, + Month = 7, + Day = 4, + ObservanceRule = ObservanceRule.None, + }; + + private static HolidayRule MakeValidNthWeekday( ) => new( ) { + Name = "Test Nth", + RuleType = HolidayRuleType.NthWeekdayOfMonth, + Month = 1, + DayOfWeek = DayOfWeek.Monday, + WeekNumber = 3, + ObservanceRule = ObservanceRule.None, + }; + + private static HolidayRule MakeValidLastWeekday( ) => new( ) { + Name = "Test Last", + RuleType = HolidayRuleType.LastWeekdayOfMonth, + Month = 5, + DayOfWeek = DayOfWeek.Monday, + ObservanceRule = ObservanceRule.None, + }; + + #endregion + + // ── Valid Rules ──────────────────────────────────────────────────────────── + + [TestMethod] + public void Valid_FixedDate_ReturnsSuccess( ) { + ValidationResult? result = HolidayRuleValidator.Validate( MakeValidFixedDate( ) ); + Assert.AreEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void Valid_NthWeekday_ReturnsSuccess( ) { + ValidationResult? result = HolidayRuleValidator.Validate( MakeValidNthWeekday( ) ); + Assert.AreEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void Valid_LastWeekday_ReturnsSuccess( ) { + ValidationResult? result = HolidayRuleValidator.Validate( MakeValidLastWeekday( ) ); + Assert.AreEqual( ValidationResult.Success, result ); + } + + // ── Name Required ────────────────────────────────────────────────────────── + + [TestMethod] + public void MissingName_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.Name = ""; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + Assert.Contains( "Name", result!.ErrorMessage! ); + } + + // ── Month Required ───────────────────────────────────────────────────────── + + [TestMethod] + public void MissingMonth_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.Month = null; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + Assert.Contains( "Month", result!.ErrorMessage! ); + } + + [TestMethod] + public void MonthZero_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.Month = 0; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void MonthThirteen_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.Month = 13; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + // ── FixedDate Specifics ──────────────────────────────────────────────────── + + [TestMethod] + public void FixedDate_MissingDay_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.Day = null; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void FixedDate_DayZero_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.Day = 0; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void FixedDate_Day32_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.Day = 32; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void FixedDate_Feb30_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.Month = 2; + rule.Day = 30; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void FixedDate_WithWeekNumber_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.WeekNumber = 1; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void FixedDate_WithDayOfWeek_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.DayOfWeek = DayOfWeek.Monday; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + // ── NthWeekday Specifics ─────────────────────────────────────────────────── + + [TestMethod] + public void NthWeekday_MissingDayOfWeek_Fails( ) { + HolidayRule rule = MakeValidNthWeekday( ); + rule.DayOfWeek = null; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void NthWeekday_MissingWeekNumber_Fails( ) { + HolidayRule rule = MakeValidNthWeekday( ); + rule.WeekNumber = null; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void NthWeekday_WeekNumberZero_Fails( ) { + HolidayRule rule = MakeValidNthWeekday( ); + rule.WeekNumber = 0; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void NthWeekday_WeekNumberSix_Fails( ) { + HolidayRule rule = MakeValidNthWeekday( ); + rule.WeekNumber = 6; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void NthWeekday_WithDay_Fails( ) { + HolidayRule rule = MakeValidNthWeekday( ); + rule.Day = 15; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + // ── LastWeekday Specifics ────────────────────────────────────────────────── + + [TestMethod] + public void LastWeekday_MissingDayOfWeek_Fails( ) { + HolidayRule rule = MakeValidLastWeekday( ); + rule.DayOfWeek = null; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void LastWeekday_WithDay_Fails( ) { + HolidayRule rule = MakeValidLastWeekday( ); + rule.Day = 1; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void LastWeekday_WithWeekNumber_Fails( ) { + HolidayRule rule = MakeValidLastWeekday( ); + rule.WeekNumber = 3; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + // ── ObservanceRule Validation ────────────────────────────────────────────── + + [TestMethod] + public void ObservanceRule_NonNone_OnNthWeekday_Fails( ) { + HolidayRule rule = MakeValidNthWeekday( ); + rule.ObservanceRule = ObservanceRule.SaturdayToFriday_SundayToMonday; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + Assert.Contains( "ObservanceRule", result!.ErrorMessage! ); + } + + [TestMethod] + public void ObservanceRule_NonNone_OnLastWeekday_Fails( ) { + HolidayRule rule = MakeValidLastWeekday( ); + rule.ObservanceRule = ObservanceRule.NearestWeekday; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void ObservanceRule_OnFixedDate_Allowed( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.ObservanceRule = ObservanceRule.SaturdayToFriday_SundayToMonday; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreEqual( ValidationResult.Success, result ); + } + + // ── Time Window Validation ───────────────────────────────────────────────── + + [TestMethod] + public void TimeWindow_AllSet_Valid( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.WindowStart = new TimeOnly( 9, 30 ); + rule.WindowEnd = new TimeOnly( 16, 0 ); + rule.WindowTimeZoneId = "America/New_York"; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void TimeWindow_PartialSet_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.WindowStart = new TimeOnly( 9, 30 ); + // WindowEnd and WindowTimeZoneId are null + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void TimeWindow_StartAfterEnd_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.WindowStart = new TimeOnly( 16, 0 ); + rule.WindowEnd = new TimeOnly( 9, 30 ); + rule.WindowTimeZoneId = "America/New_York"; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void TimeWindow_InvalidTimezone_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.WindowStart = new TimeOnly( 9, 30 ); + rule.WindowEnd = new TimeOnly( 16, 0 ); + rule.WindowTimeZoneId = "Invalid/Timezone"; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + // ── Year Range Validation ────────────────────────────────────────────────── + + [TestMethod] + public void YearRange_StartAfterEnd_Fails( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.YearStart = 2030; + rule.YearEnd = 2025; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreNotEqual( ValidationResult.Success, result ); + } + + [TestMethod] + public void YearRange_Equal_Valid( ) { + HolidayRule rule = MakeValidFixedDate( ); + rule.YearStart = 2026; + rule.YearEnd = 2026; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + Assert.AreEqual( ValidationResult.Success, result ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleCalculatorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleCalculatorTests.cs new file mode 100644 index 0000000..3ef9b31 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleCalculatorTests.cs @@ -0,0 +1,1853 @@ +using Werkr.Core.Scheduling; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Extensions; +using Werkr.Data.Calendar.Models; +using Werkr.Data.Collections; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Tests.Data.Unit.Scheduling; + +[TestClass] +public class ScheduleCalculatorTests { + + #region Helpers + + private static StartDateTimeInfo MakeStart( DateTime dt, TimeZoneInfo tz ) => new( ) { + Date = DateOnly.FromDateTime( dt ), + Time = TimeOnly.FromDateTime( dt ), + TimeZone = tz + }; + + private static ExpirationDateTimeInfo MakeExpiration( DateTime dt, TimeZoneInfo tz ) => new( ) { + Date = DateOnly.FromDateTime( dt ), + Time = TimeOnly.FromDateTime( dt ), + TimeZone = tz + }; + + private static DbSchedule TestDb( ) => new( ) { Name = "Test" }; + + #endregion Helpers + + + #region Static Test Fields + + #region DateTime and TimeZone combinations + + internal static DateTime StartUTC = new( 2020, 1, 1, 12, 0, 0, DateTimeKind.Utc ); + internal static TimeZoneInfo UtcTz = TimeZoneInfo.Utc; + + internal static DateTime StartLocal = new( 2020, 1, 1, 12, 0, 0, DateTimeKind.Local ); + internal static TimeZoneInfo LocalTz = TimeZoneInfo.Local; + + internal static DateTime StartUnspecified = new( 2020, 1, 1, 12, 0, 0, DateTimeKind.Unspecified ); + internal static TimeZoneInfo DatelineTz = TimeZoneInfo.FindSystemTimeZoneById( "Dateline Standard Time" ); + + internal static DateTime StartPlus14 = DateTimeOffset.Parse( "2020-01-01T12:00:00.0000000+14:00" ).DateTime; + internal static TimeZoneInfo LineIslandsTz = TimeZoneInfo.FindSystemTimeZoneById( "Line Islands Standard Time" ); + + internal static DateTime StartPlus13 = DateTimeOffset.Parse( "2020-01-01T12:00:00.0000000+13:00" ).DateTime; + internal static TimeZoneInfo SamoaTz = TimeZoneInfo.FindSystemTimeZoneById( "Samoa Standard Time" ); + + internal static DateTime StartPlus1245 = DateTimeOffset.Parse( "2020-01-01T12:00:00.0000000+12:45" ).DateTime; + internal static TimeZoneInfo ChathamIslandsTz = TimeZoneInfo.FindSystemTimeZoneById( "Chatham Islands Standard Time" ); + + internal static DateTime StartMinus330 = DateTimeOffset.Parse( "2020-01-01T12:00:00.0000000-03:30" ).DateTime; + internal static TimeZoneInfo NewfoundlandTz = TimeZoneInfo.FindSystemTimeZoneById( "Newfoundland Standard Time" ); + + internal static DateTime EndOfWindow = new( 2025, 12, 31, 23, 59, 59, DateTimeKind.Utc ); + + #endregion DateTime and TimeZone combinations + + #region RepeatOption + + internal static ScheduleRepeatOptions IntervalGreaterThanDuration = new() { RepeatIntervalMinutes = 5, RepeatDurationMinutes = 4 }; + internal static ScheduleRepeatOptions IntervalHalfDuration = new() { RepeatIntervalMinutes = 5, RepeatDurationMinutes = 10 }; + internal static ScheduleRepeatOptions IntervalMaxDurationMax = new() { RepeatIntervalMinutes = 1439, RepeatDurationMinutes = 1439 }; + internal static ScheduleRepeatOptions IntervalMinDurationMax = new() { RepeatIntervalMinutes = 1, RepeatDurationMinutes = 1439 }; + internal static ScheduleRepeatOptions IntervalHourlyDurationMax = new() { RepeatIntervalMinutes = 60, RepeatDurationMinutes = 1439 }; + internal static ScheduleRepeatOptions IntervalMinDurationMin = new() { RepeatIntervalMinutes = 1, RepeatDurationMinutes = -1 }; + internal static ScheduleRepeatOptions IntervalMinDurationZero = new() { RepeatIntervalMinutes = 1, RepeatDurationMinutes = 0 }; + internal static ScheduleRepeatOptions Interval15MinDurationTwoHours = new() { RepeatIntervalMinutes = 15, RepeatDurationMinutes = 120 }; + + #endregion RepeatOption + + #region Expiration DateTimeInfo + + internal static ExpirationDateTimeInfo ExpirationBeforeStartUtc = MakeExpiration( StartUTC.AddMinutes( -1 ), UtcTz ); + internal static ExpirationDateTimeInfo ExpirationOneDayAfterStartLocal = MakeExpiration( StartLocal.AddDays( 1 ), LocalTz ); + internal static ExpirationDateTimeInfo ExpirationOneWeekAfterStartUnspecified = MakeExpiration( StartUnspecified.AddDays( 7 ), DatelineTz ); + internal static ExpirationDateTimeInfo ExpirationOneMonthAfterStartPlus14 = MakeExpiration( StartPlus14.AddMonths( 1 ), LineIslandsTz ); + internal static ExpirationDateTimeInfo ExpirationSixMonthsAfterStartPlus13 = MakeExpiration( StartPlus13.AddMonths( 6 ), SamoaTz ); + internal static ExpirationDateTimeInfo ExpirationOneYearAfterStartPlus1245 = MakeExpiration( StartPlus1245.AddYears( 1 ), ChathamIslandsTz ); + internal static ExpirationDateTimeInfo ExpirationTwoYearAfterStartMinus330 = MakeExpiration( StartMinus330.AddYears( 2 ), NewfoundlandTz ); + internal static ExpirationDateTimeInfo ExpirationAfterEndOfWindow = MakeExpiration( EndOfWindow.AddMinutes( 1 ), UtcTz ); + + #endregion Expiration DateTimeInfo + + #region Daily Recurrence + + internal static DailyRecurrence NegativeDays = new() { DayInterval = -1 }; + internal static DailyRecurrence ZeroDays = new() { DayInterval = 0 }; + internal static DailyRecurrence EveryDay = new() { DayInterval = 1 }; + internal static DailyRecurrence EveryThreeDays = new() { DayInterval = 3 }; + internal static DailyRecurrence EverySevenDays = new() { DayInterval = 7 }; + internal static DailyRecurrence EveryEightDays = new() { DayInterval = 8 }; + internal static DailyRecurrence EveryFourteenDays = new() { DayInterval = 14 }; + internal static DailyRecurrence EveryThirtyDays = new() { DayInterval = 30 }; + + #endregion Daily Recurrence + + #region Weekly Recurrence + + internal static WeeklyRecurrence NegativeWeeksEveryDay = new() { WeekInterval = -1, DaysOfWeek = (DaysOfWeek)127 }; + internal static WeeklyRecurrence ZeroWeeksMondays = new() { WeekInterval = 0, DaysOfWeek = DaysOfWeek.Monday }; + internal static WeeklyRecurrence EveryWeekEveryDay = new() { WeekInterval = 1, DaysOfWeek = (DaysOfWeek)127 }; + internal static WeeklyRecurrence EveryTwoWeeksMWFS = new() { WeekInterval = 2, DaysOfWeek = (DaysOfWeek)85 }; + internal static WeeklyRecurrence EveryThreeWeeksTuThSat = new() { WeekInterval = 3, DaysOfWeek = (DaysOfWeek)42 }; + internal static WeeklyRecurrence EveryFourWeeksFriSatSun = new() { WeekInterval = 4, DaysOfWeek = (DaysOfWeek)112 }; + internal static WeeklyRecurrence EverySixWeeksMTWThF = new() { WeekInterval = 6, DaysOfWeek = (DaysOfWeek)31 }; + internal static WeeklyRecurrence EveryEightWeeksOnWed = new() { WeekInterval = 8, DaysOfWeek = DaysOfWeek.Wednesday }; + internal static WeeklyRecurrence EveryHundredAndSevenWeeksOnFri = new() { WeekInterval = 107, DaysOfWeek = DaysOfWeek.Friday }; + + #endregion Weekly Recurrence + + #region Monthly Recurrence + + #region DayNumbersWithinMonths + + internal static MonthlyRecurrence JanuaryDayNum1 = new() { MonthsOfYear = (MonthsOfYear)16, DayNumbers = [1] }; + internal static MonthlyRecurrence DecemberDayNum27 = new() { MonthsOfYear = (MonthsOfYear)32768, DayNumbers = [27] }; + internal static MonthlyRecurrence QuarterlyDayNum = new() { MonthsOfYear = (MonthsOfYear)9360, DayNumbers = [1, 3, 5, 8, 15, -8, -5, -3, -2, -1] }; + internal static MonthlyRecurrence QuarterlyDayNum2 = new() { MonthsOfYear = (MonthsOfYear)18720, DayNumbers = [2, 4, 6, 8, 10, 12, 14, -14, -12, -10, -8, -6, -4, -2] }; + internal static MonthlyRecurrence JanMaySepFirstDayNumFifteenthLast = new() { MonthsOfYear = (MonthsOfYear)4368, DayNumbers = [1, 15, -1] }; + internal static MonthlyRecurrence MarJulNovDayNum = new() { MonthsOfYear = (MonthsOfYear)17472, DayNumbers = [1, 4, 5, 8, 15, -1, -2, -5, -8] }; + internal static MonthlyRecurrence FirstHalfOfYearDayNumsOn5 = new() { MonthsOfYear = (MonthsOfYear)1008, DayNumbers = [5, 10, 15, 20, 25, 30] }; + internal static MonthlyRecurrence LastHalfOfYearDayNumsFirstPlusLastWeek = new() { MonthsOfYear = (MonthsOfYear)64512, DayNumbers = [1, -5, -4, -3, -2, -1] }; + internal static MonthlyRecurrence AllMonthsAllDaysDayNum = new() { MonthsOfYear = (MonthsOfYear)65520, DayNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] }; + internal static MonthlyRecurrence AllMonthsAllDaysDayNumTwice = new() { MonthsOfYear = (MonthsOfYear)65520, DayNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, -1, -2, -3, -4, -5, -6, -7, -8, -9, -10, -11, -12, -13, -14, -15, -16, -17, -18, -19, -20, -21, -22, -23, -24, -25, -26, -27, -28, -29, -30, -31] }; + + #endregion DayNumbersWithinMonths + + #region WeekNumberWithinMonth + + internal static MonthlyRecurrence JanuaryFirstMonday = new() { MonthsOfYear = (MonthsOfYear)16, WeekNumber = (WeekNumberWithinMonth)1, DaysOfWeek = (DaysOfWeek)1 }; + internal static MonthlyRecurrence DecemberThirdWednesday = new() { MonthsOfYear = (MonthsOfYear)32768, WeekNumber = (WeekNumberWithinMonth)4, DaysOfWeek = (DaysOfWeek)8 }; + internal static MonthlyRecurrence QuarterlyWeekNum = new() { MonthsOfYear = (MonthsOfYear)9360, WeekNumber = (WeekNumberWithinMonth)6, DaysOfWeek = (DaysOfWeek)31 }; + internal static MonthlyRecurrence QuarterlyWeekNum2 = new() { MonthsOfYear = (MonthsOfYear)18720, WeekNumber = (WeekNumberWithinMonth)5, DaysOfWeek = (DaysOfWeek)5 }; + internal static MonthlyRecurrence JanMaySepWeekNum = new() { MonthsOfYear = (MonthsOfYear)4368, WeekNumber = (WeekNumberWithinMonth)21, DaysOfWeek = (DaysOfWeek)21 }; + internal static MonthlyRecurrence MarJulNovWeekNum = new() { MonthsOfYear = (MonthsOfYear)17472, WeekNumber = (WeekNumberWithinMonth)42, DaysOfWeek = (DaysOfWeek)96 }; + internal static MonthlyRecurrence FirstHalfOfYearWeekNum = new() { MonthsOfYear = (MonthsOfYear)1008, WeekNumber = (WeekNumberWithinMonth)6, DaysOfWeek = (DaysOfWeek)2 }; + internal static MonthlyRecurrence LastHalfOfYearWeekNum = new() { MonthsOfYear = (MonthsOfYear)64512, WeekNumber = (WeekNumberWithinMonth)56, DaysOfWeek = (DaysOfWeek)85 }; + internal static MonthlyRecurrence AllMonthsAllDaysWeekNum = new() { MonthsOfYear = (MonthsOfYear)65520, WeekNumber = (WeekNumberWithinMonth)63, DaysOfWeek = (DaysOfWeek)127 }; + + #endregion WeekNumberWithinMonth + + #endregion Monthly Recurrence + + #endregion Static Test Fields + + + #region CalculateOccurrences OnlyStartDateTimeInfo + + [TestMethod] + public void CalculateOccurrences_UtcDt_ReturnsSingleOccurrence( ) { + Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( StartUTC, UtcTz ) }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_LocalDt_ReturnsSingleOccurrence( ) { + Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( StartLocal, LocalTz ) }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UnspecDt_ReturnsSingleOccurrence( ) { + Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( StartUnspecified, DatelineTz ) }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P14Dt_ReturnsSingleOccurrence( ) { + Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( StartPlus14, LineIslandsTz ) }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P13Dt_ReturnsSingleOccurrence( ) { + Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( StartPlus13, SamoaTz ) }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P1245Dt_ReturnsSingleOccurrence( ) { + Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ) }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_M330Dt_ReturnsSingleOccurrence( ) { + Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( StartMinus330, NewfoundlandTz ) }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_EndOfWindow_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( EndOfWindow, UtcTz ) }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_AfterEndOfWindow_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( EndOfWindow.AddDays( 1 ), UtcTz ) }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + #endregion CalculateOccurrences OnlyStartDateTimeInfo + + + #region CalculateOccurrences RepeatOptions + + [TestMethod] + public void CalculateOccurrences_UtcDtRepeatOptionsIntervalGtrThanDuration_ReturnsSingleOccurrence( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = IntervalGreaterThanDuration + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_LocalDtRepeatOptionsIntervalHalfDuration_ReturnsThreeOccurrences( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz ), + RepeatOptions = IntervalHalfDuration + }; + DateTime lastRepeatTime = schedule.StartDateTime!.UtcTime.AddMinutes( IntervalHalfDuration.RepeatDurationMinutes ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 3, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( lastRepeatTime, occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UnspecDtMaxRepeatOptionsIntervalMaxDurationMax_RepeatsOnce( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz ), + RepeatOptions = IntervalMaxDurationMax + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 2, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddMinutes( IntervalMaxDurationMax.RepeatIntervalMinutes ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P14DtRepeatOptionsMinIntervalMaxDuration_RepeatsOncePerMinuteForOneDay( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz ), + RepeatOptions = IntervalMinDurationMax + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1440, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P13DtRepeatOptionsIntervalHourlyMaxDuration_RepeatsOnceAnHourForOneDay( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz ), + RepeatOptions = IntervalHourlyDurationMax + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 24, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddHours( 23 ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P1245DtRepeatOptionsMinIntervalMinDuration_RepeatsUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + RepeatOptions = IntervalMinDurationMin + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 3156585, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( EndOfWindow.AddSeconds( -59 ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_M330DtRepeatOptionsMinIntervalZeroDuration_DoesNotRepeat( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartMinus330, NewfoundlandTz ), + RepeatOptions = IntervalMinDurationZero + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UtcDtRepeatOptionsInterval15mDuration2h_Repeats9Times( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = Interval15MinDurationTwoHours + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 9, occurrences.Count( ) ); + DateTime occurrenceTime = schedule.StartDateTime!.UtcTime; + foreach (DateTime occurrence in occurrences) { + Assert.AreEqual( occurrenceTime, occurrence ); + occurrenceTime = occurrenceTime.AddMinutes( Interval15MinDurationTwoHours.RepeatIntervalMinutes ); + } + } + + [TestMethod] + public void CalculateOccurrences_EOWRepeatOptions_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( EndOfWindow, UtcTz ), + RepeatOptions = Interval15MinDurationTwoHours + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + #endregion CalculateOccurrences RepeatOptions + + + #region CalculateOccurrences ExpirationDateTimeInfo + + [TestMethod] + public void CalculateOccurrences_UtcDtExpBeforeStart_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + Expiration = ExpirationBeforeStartUtc + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UtcDtExp1dAfter_ReturnsOneOccurrence( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + Expiration = ExpirationOneDayAfterStartLocal + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UtcDtRepeatOptionsIntervalGtrThanDurationExpBeforeStart_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = IntervalGreaterThanDuration, + Expiration = ExpirationBeforeStartUtc + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_LocalDTRepeatOptionsIntervalHalfDurationExp1dAfter_ReturnsThreeOccurrences( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz ), + RepeatOptions = IntervalHalfDuration, + Expiration = ExpirationOneDayAfterStartLocal + }; + DateTime lastRepeatTime = schedule.StartDateTime!.UtcTime.AddMinutes( IntervalHalfDuration.RepeatDurationMinutes ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 3, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( lastRepeatTime, occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UnspecDTRepeatOptionsMaxIntervalMaxDurationExp1wAfter_ReturnsTwoOccurrences( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz ), + RepeatOptions = IntervalMaxDurationMax, + Expiration = ExpirationOneWeekAfterStartUnspecified + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 2, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddMinutes( IntervalMaxDurationMax.RepeatIntervalMinutes ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P14DTRepeatOptionsMinIntervalMaxDurationExp1MAfter_RepeatsEveryMinuteForOneDay( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz ), + RepeatOptions = IntervalMinDurationMax, + Expiration = ExpirationOneMonthAfterStartPlus14 + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1440, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P13DTRepeatOptionsIntervalHourlyMaxDurationExp6MAfter_RepeatsEveryHourForOneDay( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz ), + RepeatOptions = IntervalHourlyDurationMax, + Expiration = ExpirationSixMonthsAfterStartPlus13 + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 24, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddHours( 23 ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P1245DTRepeatOptionsMinIntervalMinDurationExp1yAfter_RepeatsEveryMinuteForOneYear( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + RepeatOptions = IntervalMinDurationMin, + Expiration = ExpirationOneYearAfterStartPlus1245 + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 527040, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddYears( 1 ).AddMinutes( -1 ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_M330DTRepeatOptionsMinIntervalZeroDurationExp2yAfter_ReturnsSingleOccurrences( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartMinus330, NewfoundlandTz ), + RepeatOptions = IntervalMinDurationZero, + Expiration = ExpirationTwoYearAfterStartMinus330 + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UtcRepeatOptions15mInterval2hDurationExpAfterWindow_ReturnsNineOccurrences( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = Interval15MinDurationTwoHours, + Expiration = ExpirationAfterEndOfWindow + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 9, occurrences.Count( ) ); + DateTime occurrenceTime = schedule.StartDateTime!.UtcTime; + foreach (DateTime occurrence in occurrences) { + Assert.AreEqual( occurrenceTime, occurrence ); + occurrenceTime = occurrenceTime.AddMinutes( Interval15MinDurationTwoHours.RepeatIntervalMinutes ); + } + } + + [TestMethod] + public void CalculateOccurrences_EowUtcDtExpAfterEnd_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( EndOfWindow, UtcTz ), + RepeatOptions = Interval15MinDurationTwoHours, + Expiration = ExpirationAfterEndOfWindow + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + #endregion CalculateOccurrences ExpirationDateTimeInfo + + + #region CalculateOccurrences DailyRecurrence + + [TestMethod] + public void CalculateOccurrences_UtcDtNegativeDays_ReturnsSingleOccurrence( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + DailyRecurrence = NegativeDays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_LocalDtZeroDays_ReturnsSingleOccurrence( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz ), + DailyRecurrence = ZeroDays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UnspecDtEveryDay_ReturnsOneOccurrencePerDayUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz ), + DailyRecurrence = EveryDay + }; + DateTime endOfWindowDate = DateOnly + .FromDateTime( EndOfWindow ) + .ToDateTime( + TimeOnly.FromDateTime( schedule.StartDateTime!.UtcTime ), + DateTimeKind.Utc + ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 2191, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( endOfWindowDate, occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P14DtEveryThreeDays_ReturnsOneOccurrenceEveryThreeDaysUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz ), + DailyRecurrence = EveryThreeDays + }; + DateTime endOfWindowDate = DateOnly + .FromDateTime( schedule.StartDateTime!.UtcTime.AddDays( 2190 ) ) + .ToDateTime( + TimeOnly.FromDateTime( schedule.StartDateTime!.UtcTime ), + DateTimeKind.Utc + ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 731, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( endOfWindowDate, occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P13DtEverySevenDays_ReturnsOneOccurrenceEverySevenDaysUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz ), + DailyRecurrence = EverySevenDays + }; + DateOnly endOfWindowDate = DateOnly.FromDateTime( schedule.StartDateTime!.UtcTime.AddDays( 2191 ) ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 314, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + // Compare date only — UTC hour may differ from start due to DST transitions in Samoa timezone. + Assert.AreEqual( endOfWindowDate, DateOnly.FromDateTime( occurrences.Last( ) ) ); + } + + [TestMethod] + public void CalculateOccurrences_P1245DtEveryEightDays_ReturnsOneOccurrenceEveryEightDaysUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + DailyRecurrence = EveryEightDays + }; + DateTime endOfWindowDate = DateOnly + .FromDateTime( schedule.StartDateTime!.UtcTime.AddDays( 2192 ) ) + .ToDateTime( + TimeOnly.FromDateTime( schedule.StartDateTime!.UtcTime ), + DateTimeKind.Utc + ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 275, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( endOfWindowDate, occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_M330DtEveryFourteenDays_ReturnsOneOccurrenceEveryFourteenDaysUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartMinus330, NewfoundlandTz ), + DailyRecurrence = EveryFourteenDays + }; + DateTime endOfWindowDate = DateOnly + .FromDateTime( schedule.StartDateTime!.UtcTime.AddDays( 2184 ) ) + .ToDateTime( + TimeOnly.FromDateTime( schedule.StartDateTime!.UtcTime ), + DateTimeKind.Utc + ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 157, occurrences.LongCount( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( endOfWindowDate, occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P1245DtEveryThirtyDays_ReturnsOneOccurrenceEveryThirtyDaysUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + DailyRecurrence = EveryThirtyDays + }; + DateTime endOfWindowDate = DateOnly + .FromDateTime( schedule.StartDateTime!.UtcTime.AddDays( 2190 ) ) + .ToDateTime( + TimeOnly.FromDateTime( schedule.StartDateTime!.UtcTime ), + DateTimeKind.Utc + ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 74, occurrences.LongCount( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( endOfWindowDate, occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_EndOfWindowNegativeDays_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( EndOfWindow, UtcTz ), + DailyRecurrence = NegativeDays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_AfterEndOfWindowEveryDay_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( EndOfWindow.AddDays( 1 ), UtcTz ), + DailyRecurrence = EveryDay + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UtcDtEveryDay_Returns2192OccurrencesEachOneDayApart( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + DailyRecurrence = EveryDay + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 2192, occurrences.Count( ) ); + DateTime occurrenceTime = schedule.StartDateTime!.UtcTime; + foreach (DateTime occurrence in occurrences) { + Assert.AreEqual( occurrenceTime, occurrence ); + occurrenceTime = occurrenceTime.AddDays( EveryDay.DayInterval ); + } + } + + [TestMethod] + public void CalculateOccurrences_UtcRepeatOptionsIntervalHalfDurationOccursEveryDay_Returns4383Occurrences( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = IntervalHalfDuration, + DailyRecurrence = EveryDay + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 6576, occurrences.Count( ) ); + DateTime occurrenceTime = schedule.StartDateTime!.UtcTime; + int count = 0; + foreach (DateTime occurrence in occurrences) { + count++; + Assert.AreEqual( occurrenceTime, occurrence ); + occurrenceTime = count % 3 == 0 + ? occurrenceTime + .AddMinutes( IntervalHalfDuration.RepeatIntervalMinutes * -2 ) + .AddDays( EveryDay.DayInterval ) + : occurrenceTime + .AddMinutes( IntervalHalfDuration.RepeatIntervalMinutes ); + } + } + + [TestMethod] + public void CalculateOccurrences_UtcDtDrEveryDayExpBeforeStart_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + Expiration = ExpirationBeforeStartUtc, + DailyRecurrence = EveryDay + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UtcDtDrEveryThreeDaysExp1dAfter_ReturnsOneOccurrence( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + Expiration = ExpirationOneDayAfterStartLocal, + DailyRecurrence = EveryThreeDays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UtcDtRepeatOptionsIntervalGtrThanDurationDrEveryDayExpBeforeStart_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = IntervalGreaterThanDuration, + Expiration = ExpirationBeforeStartUtc, + DailyRecurrence = EveryDay + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_LocalDTRepeatOptionsIntervalHalfDurationDrNegativeDaysExp1dAfter_ReturnsThreeOccurrences( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz ), + RepeatOptions = IntervalHalfDuration, + Expiration = ExpirationOneDayAfterStartLocal, + DailyRecurrence = NegativeDays + }; + DateTime lastRepeatTime = schedule.StartDateTime!.UtcTime.AddMinutes( IntervalHalfDuration.RepeatDurationMinutes ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 3, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( lastRepeatTime, occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UnspecDTRepeatOptionsMaxIntervalMaxDurationDrZeroDaysExp1wAfter_ReturnsTwoOccurrences( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz ), + RepeatOptions = IntervalMaxDurationMax, + Expiration = ExpirationOneWeekAfterStartUnspecified, + DailyRecurrence = ZeroDays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 2, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddMinutes( IntervalMaxDurationMax.RepeatIntervalMinutes ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P14DTRepeatOptionsMinIntervalMaxDurationDrEveryDayExp1MAfter_RepeatsEveryMinuteForThirtyOneDays( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz ), + RepeatOptions = IntervalMinDurationMax, + Expiration = ExpirationOneMonthAfterStartPlus14, + DailyRecurrence = EveryDay + }; + DateTime finalDt = DateOnly + .FromDateTime( schedule.StartDateTime!.UtcTime.AddDays( 31 ) ) + .ToDateTime( + TimeOnly.FromDateTime( schedule.StartDateTime!.UtcTime ), + DateTimeKind.Utc + ).AddMinutes( -1 ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 44640, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( finalDt, occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P13DTRepeatOptionsIntervalHourlyMaxDurationDrEveryThreeDaysExp6MAfter_ReturnsHourlyOccurrencesEveryThreeDaysForSixMonths( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz ), + RepeatOptions = IntervalHourlyDurationMax, + Expiration = ExpirationSixMonthsAfterStartPlus13, + DailyRecurrence = EveryThreeDays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1464, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddDays( 181 ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P1245DTRepeatOptionsMinIntervalMinDurationDrEverySevenDaysExp1yAfter_RepeatsEveryMinuteForOneYear( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + RepeatOptions = IntervalMinDurationMin, + Expiration = ExpirationOneYearAfterStartPlus1245, + DailyRecurrence = EverySevenDays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 527040, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddYears( 1 ).AddMinutes( -1 ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_M330DTRepeatOptionsMinIntervalZeroDurationDrEveryEightDaysExp2yAfter_ReoccursOnceEveryEightDaysForTwoYears( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartMinus330, NewfoundlandTz ), + RepeatOptions = IntervalMinDurationZero, + Expiration = ExpirationTwoYearAfterStartMinus330, + DailyRecurrence = EveryEightDays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 92, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddDays( 728 ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UtcRepeatOptions15mInterval2hDurationDrEveryFourteenDaysExpAfterWindow_RepeatsEveryFifteenMinutesForTwoHoursThenReoccursEvery14DaysUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = Interval15MinDurationTwoHours, + Expiration = ExpirationAfterEndOfWindow, + DailyRecurrence = EveryFourteenDays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1413, occurrences.Count( ) ); + DateTime occurrenceTime = schedule.StartDateTime!.UtcTime; + int count = 0; + foreach (DateTime occurrence in occurrences) { + count++; + Assert.AreEqual( occurrenceTime, occurrence ); + occurrenceTime = count % 9 == 0 + ? occurrenceTime.AddMinutes( Interval15MinDurationTwoHours.RepeatDurationMinutes * -1 ).AddDays( 14 ) + : occurrenceTime.AddMinutes( Interval15MinDurationTwoHours.RepeatIntervalMinutes ); + } + } + + [TestMethod] + public void CalculateOccurrences_EowUtcDtDrEveryThirtyDaysExpAfterEnd_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( EndOfWindow, UtcTz ), + RepeatOptions = Interval15MinDurationTwoHours, + Expiration = ExpirationAfterEndOfWindow, + DailyRecurrence = EveryThirtyDays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + #endregion CalculateOccurrences DailyRecurrence + + + #region CalculateOccurrences WeeklyRecurrence + + [TestMethod] + public void CalculateOccurrences_UtcDtWrNegativeWeeksEveryDay_ReturnsSingleOccurrence( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + WeeklyRecurrence = NegativeWeeksEveryDay + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_LocalDtWrZeroWeeksMondays_ReturnsSingleOccurrence( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz ), + WeeklyRecurrence = ZeroWeeksMondays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UnspecDtWrEveryWeekEveryDay_ReturnsOneOccurrencePerDayUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz ), + WeeklyRecurrence = EveryWeekEveryDay + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + CalculateOccurrences_SimpleWeeklyRecurrence( schedule, EndOfWindow, occurrences, 2191 ); + } + + [TestMethod] + public void CalculateOccurrences_P14DtWrEveryTwoWeeksMWFS_ReturnsOneOccurrenceEveryMWFSOnTwoWeekIntervals( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz ), + WeeklyRecurrence = EveryTwoWeeksMWFS + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + CalculateOccurrences_SimpleWeeklyRecurrence( schedule, EndOfWindow, occurrences, 627 ); + } + + [TestMethod] + public void CalculateOccurrences_P13DtWrEveryThreeWeeksTuThSat_ReturnsOneOccurrenceEveryThirdWeekOnTuThSatUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz ), + WeeklyRecurrence = EveryThreeWeeksTuThSat + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + CalculateOccurrences_SimpleWeeklyRecurrence( schedule, EndOfWindow, occurrences, 315 ); + } + + [TestMethod] + public void CalculateOccurrences_P1245DtWrEveryFourWeeksFriSatSun_ReturnsOneOccurrenceEveryFourWeeksOnFriSatSunUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + WeeklyRecurrence = EveryFourWeeksFriSatSun + }; + DateTime endOfWindowDate = DateOnly + .FromDateTime( schedule.StartDateTime!.UtcTime.AddDays( 2188 ) ) + .ToDateTime( + TimeOnly.FromDateTime( schedule.StartDateTime!.UtcTime ), + DateTimeKind.Utc + ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + CalculateOccurrences_SimpleWeeklyRecurrence( schedule, EndOfWindow, occurrences, 238 ); + Assert.AreEqual( endOfWindowDate, occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_M330DtWrEverySixWeeksMTWThF_ReturnsThreeOccurrenceInFirstWeekAndFiveOccurrencesEverySixWeeksThereAfterUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartMinus330, NewfoundlandTz ), + WeeklyRecurrence = EverySixWeeksMTWThF + }; + DateTime endOfWindowDate = DateOnly + .FromDateTime( schedule.StartDateTime!.UtcTime.AddDays( 2186 ) ) + .ToDateTime( + TimeOnly.FromDateTime( schedule.StartDateTime!.UtcTime ), + DateTimeKind.Utc + ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 263, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( endOfWindowDate, occurrences.Last( ) ); + CalculateOccurrences_SimpleWeeklyRecurrence( schedule, EndOfWindow, occurrences, 263 ); + } + + [TestMethod] + public void CalculateOccurrences_P1245DtWrEveryEightWeeksWednesday_ReturnsOneOccurrenceEvery8WeeksUntilEndOfWindow( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + WeeklyRecurrence = EveryEightWeeksOnWed + }; + DateTime endOfWindowDate = DateOnly + .FromDateTime( schedule.StartDateTime!.UtcTime.AddDays( 2184 ) ) + .ToDateTime( + TimeOnly.FromDateTime( schedule.StartDateTime!.UtcTime ), + DateTimeKind.Utc + ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 40, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( endOfWindowDate, occurrences.Last( ) ); + CalculateOccurrences_SimpleWeeklyRecurrence( schedule, EndOfWindow, occurrences, 40 ); + } + + [TestMethod] + public void CalculateOccurrences_EndOfWindowWrNegativeWeeksEveryDay_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( EndOfWindow, UtcTz ), + WeeklyRecurrence = NegativeWeeksEveryDay + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_AfterEndOfWindowWrZeroWeeksMondays_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( EndOfWindow.AddDays( 1 ), UtcTz ), + WeeklyRecurrence = ZeroWeeksMondays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UtcDtWrEveryWeekEveryDay_Returns2192OccurrencesEachOneDayApart( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + WeeklyRecurrence = EveryWeekEveryDay + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + CalculateOccurrences_SimpleWeeklyRecurrence( schedule, EndOfWindow, occurrences, 2192 ); + } + + [TestMethod] + public void CalculateOccurrences_UtcRepeatOptionsIntervalHalfDurationWrEveryTwoWeeksMWFS_RepeatsThreeTimesPerMonWedFriSunEveryTwoWeeks( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = IntervalHalfDuration, + WeeklyRecurrence = EveryTwoWeeksMWFS + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + + DateTime occurrenceTime = schedule.StartDateTime!.UtcTime; + int count = 4; + foreach (DateTime occurrence in occurrences) { + Assert.AreEqual( occurrenceTime, occurrence ); + occurrenceTime = count % 3 == 0 + ? occurrenceTime + .AddMinutes( schedule.RepeatOptions!.RepeatIntervalMinutes * -2 ) + .AddDays( count % 12 == 0 ? 8 : 2 ) + : occurrenceTime.AddMinutes( schedule.RepeatOptions!.RepeatIntervalMinutes ); + count++; + } + } + + [TestMethod] + public void CalculateOccurrences_UtcDtWrEveryThreeWeeksTuThSatExpBeforeStart_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + Expiration = ExpirationBeforeStartUtc, + WeeklyRecurrence = EveryThreeWeeksTuThSat + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UtcDtWrEveryFourWeeksFriSatSunExp1dAfter_ReturnsOneOccurrence( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + Expiration = ExpirationOneDayAfterStartLocal, + WeeklyRecurrence = EveryFourWeeksFriSatSun + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UtcDtRepeatOptionsIntervalGtrThanDurationWrEverySixWeeksMTWThFExpBeforeStart_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = IntervalGreaterThanDuration, + Expiration = ExpirationBeforeStartUtc, + WeeklyRecurrence = EverySixWeeksMTWThF + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_LocalDTRepeatOptionsIntervalHalfDurationWrEveryEightWeeksWednesdayExp1dAfter_ReturnsThreeOccurrences( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz ), + RepeatOptions = IntervalHalfDuration, + Expiration = ExpirationOneDayAfterStartLocal, + WeeklyRecurrence = EveryEightWeeksOnWed + }; + DateTime lastRepeatTime = schedule.StartDateTime!.UtcTime.AddMinutes( IntervalHalfDuration.RepeatDurationMinutes ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 3, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( lastRepeatTime, occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_UnspecDTRepeatOptionsMaxIntervalMaxDurationWrNegativeWeeksEveryDayExp1wAfter_ReturnsTwoOccurrences( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz ), + RepeatOptions = IntervalMaxDurationMax, + Expiration = ExpirationOneWeekAfterStartUnspecified, + WeeklyRecurrence = NegativeWeeksEveryDay + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 2, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddMinutes( IntervalMaxDurationMax.RepeatIntervalMinutes ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P14DTRepeatOptionsMinIntervalMaxDurationWrZeroWeeksMondaysExp1MAfter_RepeatsEveryMinuteForOneDay( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz ), + RepeatOptions = IntervalMinDurationMax, + Expiration = ExpirationOneMonthAfterStartPlus14, + WeeklyRecurrence = ZeroWeeksMondays + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1440, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddMinutes( 1439 ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P13DTRepeatOptionsIntervalHourlyMaxDurationWrEveryWeekEveryDayExp6MAfter_ReturnsHourlyOccurrencesEveryDaysForSixMonths( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz ), + RepeatOptions = IntervalHourlyDurationMax, + Expiration = ExpirationSixMonthsAfterStartPlus13, + WeeklyRecurrence = EveryWeekEveryDay + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 4368, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddDays( 182 ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P1245DTRepeatOptionsMinIntervalMinDurationWrEveryTwoWeeksMWFSExp1yAfter_RepeatsEveryMinuteForOneYear( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + RepeatOptions = IntervalMinDurationMin, + Expiration = ExpirationOneYearAfterStartPlus1245, + WeeklyRecurrence = EveryTwoWeeksMWFS + }; + TimeSpan totalScheduleTime = schedule.Expiration!.UtcTime - schedule.StartDateTime!.UtcTime; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( totalScheduleTime.TotalMinutes, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddYears( 1 ).AddMinutes( -1 ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_P1245DTRepeatOptionsMinIntervalMaxDurationWrEveryWeekEveryDayExp1yAfter_RepeatsEveryMinuteForOneYear( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + RepeatOptions = IntervalMinDurationMax, + Expiration = ExpirationOneYearAfterStartPlus1245, + WeeklyRecurrence = EveryWeekEveryDay + }; + TimeSpan totalScheduleTime = schedule.Expiration!.UtcTime - schedule.StartDateTime!.UtcTime; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + // DST offset change causes a 60-minute gap + Assert.AreEqual( totalScheduleTime.TotalMinutes - 60, occurrences.Count( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences.First( ) ); + Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddYears( 1 ).AddMinutes( -1 ), occurrences.Last( ) ); + } + + [TestMethod] + public void CalculateOccurrences_M330DTRepeatOptionsMinIntervalZeroDurationWrEveryThreeWeeksTuThSatExp2yAfter_RepeatsThreeDaysAWeekEveryThreeWeeksForTwoYears( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartMinus330, NewfoundlandTz ), + RepeatOptions = IntervalMinDurationZero, + Expiration = ExpirationTwoYearAfterStartMinus330, + WeeklyRecurrence = EveryThreeWeeksTuThSat + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + CalculateOccurrences_SimpleWeeklyRecurrence( schedule, EndOfWindow, occurrences, 105 ); + } + + [TestMethod] + public void CalculateOccurrences_UtcRepeatOptions15mInterval2hDurationWrEveryFourWeeksFriSatSunExpAfterWindow_RepeatsEveryFifteenMinutesForTwoHoursAndScheduleReoccursEveryFourWeeksOnFriSatSun( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = Interval15MinDurationTwoHours, + Expiration = ExpirationAfterEndOfWindow, + WeeklyRecurrence = EveryFourWeeksFriSatSun + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + + DateTime occurrenceTime = schedule.StartDateTime!.UtcTime; + int count = -8; + Assert.AreEqual( occurrenceTime, occurrences.First( ) ); + foreach (DateTime occurrence in occurrences) { + Assert.AreEqual( occurrenceTime, occurrence ); + occurrenceTime = count == 0 + ? occurrenceTime + .AddMinutes( schedule.RepeatOptions!.RepeatIntervalMinutes * -8 ) + .AddDays( 2 ) + : count % 9 == 0 + ? occurrenceTime + .AddMinutes( schedule.RepeatOptions!.RepeatIntervalMinutes * -8 ) + .AddDays( count % 27 == 0 ? 26 : 1 ) + : occurrenceTime.AddMinutes( schedule.RepeatOptions!.RepeatIntervalMinutes ); + count++; + } + } + + [TestMethod] + public void CalculateOccurrences_EowUtcDtWrEverySixWeeksMTWThFExpAfterEnd_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( EndOfWindow, UtcTz ), + RepeatOptions = Interval15MinDurationTwoHours, + Expiration = ExpirationAfterEndOfWindow, + WeeklyRecurrence = EverySixWeeksMTWThF + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_EowUtcDtWrEveryHundredSevenWeeksOnFriExpAfterEnd( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = Interval15MinDurationTwoHours, + Expiration = ExpirationAfterEndOfWindow, + WeeklyRecurrence = EveryHundredAndSevenWeeksOnFri + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 36, occurrences.Count( ) ); + } + + /// + /// Validates simple weekly recurrence results against a brute-force calculation. + /// Only works for schedules without repeat options (except zero-duration). + /// + public static void CalculateOccurrences_SimpleWeeklyRecurrence( + Schedule schedule, + DateTime endOfWindow, + IReadOnlyList occurrences, + int validationCount + ) { + if (schedule?.WeeklyRecurrence == null) { + throw new ArgumentNullException( nameof( schedule ), "Schedule must contain a weekly recurrence schedule." ); + } else if (endOfWindow.Kind != DateTimeKind.Utc) { + throw new ArgumentException( "End of window must be in UTC.", nameof( endOfWindow ) ); + } + + if (schedule.Expiration?.UtcTime != null && schedule.Expiration.UtcTime < endOfWindow) { + endOfWindow = schedule.Expiration.UtcTime; + } + + TimeSpan totalScheduleTime = endOfWindow - schedule.StartDateTime!.UtcTime; + DateTime occurrence = schedule.StartDateTime!.TzTime; + List calculatedOccurrences = [schedule.StartDateTime!.UtcTime]; + List dayOfWeeks = schedule.WeeklyRecurrence.DaysOfWeek.GetDaysOfWeek(); + LoopingList loopingWeek = [.. CalendarEnumExtensions.GetWeekOfDays()]; + + DayOfWeek targetDay = occurrence.DayOfWeek; + DayOfWeek startDay = loopingWeek[0]; + int firstWeekEnds = ScheduleCalculator.CalculateWeeklyOccurrences_GetDayDifference( loopingWeek, targetDay, startDay ); + + int weekNum = 0; + int weekDayCount = 0; + for (int i = 0; i < totalScheduleTime.Days; i++) { + occurrence = occurrence.AddDays( 1 ); + + if (schedule.Expiration?.UtcTime != null && occurrence > schedule.Expiration.UtcTime) { + break; + } + if (dayOfWeeks.Contains( occurrence.DayOfWeek ) && weekNum % schedule.WeeklyRecurrence.WeekInterval == 0) { + calculatedOccurrences.Add( schedule.StartDateTime!.ConvertToUtc( occurrence ) ); + } + + if ((weekDayCount == 0 && i == firstWeekEnds) || (weekDayCount > 0 && weekDayCount % 7 == 0)) { + weekDayCount++; + weekNum++; + } else if (weekDayCount > 0) { + weekDayCount++; + } + } + + Assert.HasCount( validationCount, calculatedOccurrences, "validationCount must match the number of calculatedOccurrences." ); + Assert.AreEqual( validationCount, occurrences.Count( ), "SimpleWeeklyRecurrence calculatedOccurrences must match the number of the input occurrences." ); + for (int i = 0; i < calculatedOccurrences.Count; i++) { + Assert.AreEqual( calculatedOccurrences[i], occurrences.ElementAt( i ), "SimpleWeeklyRecurrence calculatedOccurrences must also match the datetime of the input occurrences." ); + } + } + + #endregion CalculateOccurrences WeeklyRecurrence + + + #region MonthlyRecurrence + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceExpAfterEnd_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( EndOfWindow, UtcTz ), + MonthlyRecurrence = JanMaySepFirstDayNumFifteenthLast + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceJanuaryDayNum1_ReturnsSixOccurrancesOnJanuaryFirst( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + MonthlyRecurrence = JanuaryDayNum1 + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 6, occurrences.Count( ) ); + int year = 2020; + foreach (DateTime occurrence in occurrences) { + Assert.AreEqual( new DateTime( year, 1, 1, StartUTC.Hour, StartUTC.Minute, StartUTC.Second, DateTimeKind.Utc ), occurrence ); + year++; + } + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceDecemberDayNum27_ReturnsSixDecember27thsPlusOriginalOccurrence( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz ), + MonthlyRecurrence = DecemberDayNum27 + }; + DateTime dateTime = TimeZoneInfo.ConvertTimeToUtc( new DateTime( 2020, 12, 27, StartLocal.Hour, StartLocal.Minute, StartLocal.Second, DateTimeKind.Local ), LocalTz ); + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 7, occurrences.Count( ) ); + int year = 2020; + int count = 0; + foreach (DateTime occurrence in occurrences) { + if (count == 0) { + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrence ); + count++; + } else { + Assert.AreEqual( new DateTime( year, 12, dateTime.Day, dateTime.Hour, dateTime.Minute, dateTime.Second, DateTimeKind.Utc ), occurrence ); + year++; + } + } + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceQuarterlyDayNum_Returns10OccurrencesInJanAprJulOctFor6Years( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz ), + MonthlyRecurrence = QuarterlyDayNum + }; + DateTime[] occurrences = [.. ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow )]; + DateTime startTime = schedule.StartDateTime!.TzTime; + + int[] thirtyOneDayMonths = [1, 3, 5, 8, 15, 24, 27, 29, 30, 31]; + int[] april = [1, 3, 5, 8, 15, 23, 26, 28, 29, 30]; + int count = 1; + int year = 2020; + for (int i = 0; i < 4 * 10 * 6; i++) { + if (i % 10 == 0 && i != 0) { + if (count == 4) { + count = 1; + } else { + count++; + } + } + int[] intArray = count == 2 ? april : thirtyOneDayMonths; + int iterator = i % 10; + int monthOfYear = count == 1 ? 1 : count == 2 ? 4 : count == 3 ? 7 : 10; + + DateTime scheduledTime = TimeZoneInfo.ConvertTimeToUtc( new( year, monthOfYear, intArray[iterator], startTime.Hour, startTime.Minute, startTime.Second, DateTimeKind.Unspecified ), DatelineTz ); + Assert.AreEqual( scheduledTime, occurrences[i] ); + + if ((i + 1) % 40 == 0) { year++; } + } + Assert.HasCount( 240, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceQuarterlyDayNum2_Returns14OccurrencesInFebMayAugNovFor6Years( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz ), + MonthlyRecurrence = QuarterlyDayNum2 + }; + DateTime[] occurrences = [.. ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow )]; + DateTime startTime = schedule.StartDateTime!.TzTime; + + int[] february = [2, 4, 6, 8, 10, 12, 14, 15, 17, 19, 21, 23, 25, 27]; + int[] leapFebruary = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28]; + int[] thirtyOneDayMonths = [2, 4, 6, 8, 10, 12, 14, 18, 20, 22, 24, 26, 28, 30]; + int[] november = [2, 4, 6, 8, 10, 12, 14, 17, 19, 21, 23, 25, 27, 29]; + int count = 1; + int year = 2020; + Assert.AreEqual( schedule.StartDateTime!.UtcTime, occurrences[0] ); + for (int i = 0; i < 4 * 14 * 6; i++) { + if (i % 14 == 0 && i != 0) { + if (count == 4) { + count = 1; + } else { + count++; + } + } + int[] intArray = count == 1 + ? i is <= 14 or (>= 224 and < 280) ? leapFebruary + : february + : count == 4 + ? november + : thirtyOneDayMonths; + int iterator = i % 14; + int monthOfYear = count == 1 ? 2 : count == 2 ? 5 : count == 3 ? 8 : 11; + DateTime tzTime = new( year, monthOfYear, intArray[iterator], startTime.Hour, startTime.Minute, startTime.Second, DateTimeKind.Unspecified ); + DateTime scheduledTime = TimeZoneInfo.ConvertTimeToUtc( tzTime, LineIslandsTz ); + Assert.AreEqual( scheduledTime, occurrences[i + 1] ); + + if ((i + 1) % 56 == 0) { year++; } + } + Assert.HasCount( 337, occurrences ); + } + + // --- Monthly stub tests (values need to be calculated when algorithm runs) --- + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceJanMaySepFirstDayNumFifteenthLast_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz ), + Expiration = ExpirationSixMonthsAfterStartPlus13, + MonthlyRecurrence = JanMaySepFirstDayNumFifteenthLast + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 6, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceMarJulNovDayNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + Expiration = ExpirationOneYearAfterStartPlus1245, + MonthlyRecurrence = MarJulNovDayNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 28, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceFirstHalfOfYearDayNumsOn5_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartMinus330, NewfoundlandTz ), + RepeatOptions = IntervalMinDurationZero, + MonthlyRecurrence = FirstHalfOfYearDayNumsOn5 + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 211, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceLastHalfOfYearDayNumsFirstPlusLastWeek_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + Expiration = ExpirationAfterEndOfWindow, + MonthlyRecurrence = LastHalfOfYearDayNumsFirstPlusLastWeek + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 217, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceAllMonthsAllDaysDayNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz ), + RepeatOptions = IntervalGreaterThanDuration, + MonthlyRecurrence = AllMonthsAllDaysDayNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 2192, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceAllMonthsAllDaysDayNumTwice_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz ), + Expiration = ExpirationOneDayAfterStartLocal, + MonthlyRecurrence = AllMonthsAllDaysDayNumTwice + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 1, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceJanuaryFirstMonday_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz ), + MonthlyRecurrence = JanuaryFirstMonday + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 2, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceDecemberThirdWednesday_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz ), + MonthlyRecurrence = DecemberThirdWednesday + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 7, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceQuarterlyWeekNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + MonthlyRecurrence = QuarterlyWeekNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 241, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceQuarterlyWeekNum2_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartMinus330, NewfoundlandTz ), + MonthlyRecurrence = QuarterlyWeekNum2 + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 63, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceJanMaySepWeekNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + MonthlyRecurrence = JanMaySepWeekNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 113, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceMarJulNovWeekNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz ), + Expiration = ExpirationAfterEndOfWindow, + MonthlyRecurrence = MarJulNovWeekNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 73, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceFirstHalfOfYearWeekNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz ), + RepeatOptions = IntervalGreaterThanDuration, + MonthlyRecurrence = FirstHalfOfYearWeekNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 72, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceLastHalfOfYearWeekNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz ), + Expiration = ExpirationOneDayAfterStartLocal, + MonthlyRecurrence = LastHalfOfYearWeekNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 3, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_MonthlyRecurrenceAllMonthsAllDaysWeekNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz ), + RepeatOptions = IntervalMaxDurationMax, + MonthlyRecurrence = AllMonthsAllDaysWeekNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 4011, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_ExpirationBeforeStartUtcIntervalGreaterThanDurationMrJanuaryDayNum1_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = IntervalGreaterThanDuration, + Expiration = ExpirationBeforeStartUtc, + MonthlyRecurrence = JanuaryDayNum1 + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalHalfDurationExpirationOneDayAfterStartLocalMrDecemberDayNum27_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz ), + RepeatOptions = IntervalHalfDuration, + Expiration = ExpirationOneDayAfterStartLocal, + MonthlyRecurrence = DecemberDayNum27 + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 3, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalMaxDurationMaxExpirationOneWeekAfterStartUnspecifiedMrQuarterlyDayNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz ), + RepeatOptions = IntervalMaxDurationMax, + Expiration = ExpirationOneWeekAfterStartUnspecified, + MonthlyRecurrence = QuarterlyDayNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 6, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalMinDurationMaxExpirationOneMonthAfterStartPlus14MrQuarterlyDayNum2_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz ), + RepeatOptions = IntervalMinDurationMax, + Expiration = ExpirationOneMonthAfterStartPlus14, + MonthlyRecurrence = QuarterlyDayNum2 + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 1440, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalHourlyDurationMaxExpirationSixMonthsAfterStartPlus13MrJanMaySepFirstDayNumFifteenthLast_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz ), + RepeatOptions = IntervalHourlyDurationMax, + Expiration = ExpirationSixMonthsAfterStartPlus13, + MonthlyRecurrence = JanMaySepFirstDayNumFifteenthLast + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 144, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalMinDurationMinExpirationOneYearAfterStartPlus1245MrMarJulNovDayNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + RepeatOptions = IntervalMinDurationMin, + Expiration = ExpirationOneYearAfterStartPlus1245, + MonthlyRecurrence = MarJulNovDayNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 527040, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalMinDurationZeroExpirationTwoYearAfterStartMinus330MrFirstHalfOfYearDayNumsOn5_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartMinus330, NewfoundlandTz ), + RepeatOptions = IntervalMinDurationZero, + Expiration = ExpirationTwoYearAfterStartMinus330, + MonthlyRecurrence = FirstHalfOfYearDayNumsOn5 + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 71, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_ExpirationAfterEndOfWindowInterval15MinDurationTwoHoursMrLastHalfOfYearDayNumsFirstPlusLastWeek_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = Interval15MinDurationTwoHours, + Expiration = ExpirationAfterEndOfWindow, + MonthlyRecurrence = LastHalfOfYearDayNumsFirstPlusLastWeek + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 1953, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_ExpirationBeforeStartUtcIntervalGreaterThanDurationMrAllMonthsAllDaysDayNum_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz ), + RepeatOptions = IntervalGreaterThanDuration, + Expiration = ExpirationBeforeStartUtc, + MonthlyRecurrence = AllMonthsAllDaysDayNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalHalfDurationExpirationOneDayAfterStartLocalMrAllMonthsAllDaysDayNumTwice_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz ), + RepeatOptions = IntervalHalfDuration, + Expiration = ExpirationOneDayAfterStartLocal, + MonthlyRecurrence = AllMonthsAllDaysDayNumTwice + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 3, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalMaxDurationMaxExpirationOneWeekAfterStartUnspecifiedMrJanuaryFirstMonday_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz ), + RepeatOptions = IntervalMaxDurationMax, + Expiration = ExpirationOneWeekAfterStartUnspecified, + MonthlyRecurrence = JanuaryFirstMonday + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 2, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalMinDurationMaxExpirationOneMonthAfterStartPlus14MrDecemberThirdWednesday_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz ), + RepeatOptions = IntervalMinDurationMax, + Expiration = ExpirationOneMonthAfterStartPlus14, + MonthlyRecurrence = DecemberThirdWednesday + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 1440, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalHourlyDurationMaxExpirationSixMonthsAfterStartPlus13MrQuarterlyWeekNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz ), + RepeatOptions = IntervalHourlyDurationMax, + Expiration = ExpirationSixMonthsAfterStartPlus13, + MonthlyRecurrence = QuarterlyWeekNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 504, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalMinDurationMinExpirationOneYearAfterStartPlus1245MrQuarterlyWeekNum2_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartMinus330, NewfoundlandTz ), + RepeatOptions = IntervalMinDurationMin, + Expiration = ExpirationOneYearAfterStartPlus1245, + MonthlyRecurrence = QuarterlyWeekNum2 + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 526005, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalMinDurationZeroExpirationTwoYearAfterStartMinus330MrJanMaySepWeekNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz ), + RepeatOptions = IntervalMinDurationZero, + Expiration = ExpirationTwoYearAfterStartMinus330, + MonthlyRecurrence = JanMaySepWeekNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 37, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_ExpirationAfterEndOfWindowInterval15MinDurationTwoHoursMrMarJulNovWeekNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz ), + RepeatOptions = Interval15MinDurationTwoHours, + Expiration = ExpirationAfterEndOfWindow, + MonthlyRecurrence = MarJulNovWeekNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 657, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_ExpirationBeforeStartUtcIntervalGreaterThanDurationMrFirstHalfOfYearWeekNum_ReturnsEmptyEnumerable( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz ), + RepeatOptions = IntervalGreaterThanDuration, + Expiration = ExpirationBeforeStartUtc, + MonthlyRecurrence = FirstHalfOfYearWeekNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.AreEqual( 0, occurrences.Count( ) ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalHalfDurationExpirationOneDayAfterStartLocalMrLastHalfOfYearWeekNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz ), + RepeatOptions = IntervalHalfDuration, + Expiration = ExpirationOneDayAfterStartLocal, + MonthlyRecurrence = LastHalfOfYearWeekNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 9, occurrences ); + } + + [TestMethod] + public void CalculateOccurrences_IntervalMaxDurationMaxExpirationOneWeekAfterStartUnspecifiedMrAllMonthsAllDaysWeekNum_Returns( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz ), + RepeatOptions = IntervalMaxDurationMax, + Expiration = ExpirationOneWeekAfterStartUnspecified, + MonthlyRecurrence = AllMonthsAllDaysWeekNum + }; + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow ); + Assert.HasCount( 59, occurrences ); + } + + #endregion MonthlyRecurrence + +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleDescriptionBuilderTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleDescriptionBuilderTests.cs new file mode 100644 index 0000000..48019f1 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleDescriptionBuilderTests.cs @@ -0,0 +1,164 @@ +using Werkr.Core.Scheduling; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Models; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Tests.Data.Unit.Scheduling; + +[TestClass] +public class ScheduleDescriptionBuilderTests { + + #region Helpers + + private static StartDateTimeInfo MakeStart( DateTime dt, TimeZoneInfo tz ) => new( ) { + Date = DateOnly.FromDateTime( dt ), + Time = TimeOnly.FromDateTime( dt ), + TimeZone = tz + }; + + private static ExpirationDateTimeInfo MakeExpiration( DateTime dt, TimeZoneInfo tz ) => new( ) { + Date = DateOnly.FromDateTime( dt ), + Time = TimeOnly.FromDateTime( dt ), + TimeZone = tz + }; + + private static DbSchedule TestDb( ) => new( ) { Name = "Test" }; + + private static readonly DateTime s_testDate = new( 2025, 3, 15, 9, 0, 0, DateTimeKind.Utc ); + + #endregion Helpers + + + [TestMethod] + public void GetFriendlyDescription_OnceSchedule_ReturnsOnceWithDate( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( s_testDate, TimeZoneInfo.Utc ) + }; + string desc = ScheduleDescriptionBuilder.GetFriendlyDescription( schedule ); + Assert.StartsWith( "Once on 2025-03-15", desc ); + Assert.Contains( "9:00 AM", desc ); + Assert.Contains( "UTC", desc ); + } + + [TestMethod] + public void GetFriendlyDescription_Daily_ReturnsDailyDescription( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( s_testDate, TimeZoneInfo.Utc ), + DailyRecurrence = new() { DayInterval = 1 } + }; + string desc = ScheduleDescriptionBuilder.GetFriendlyDescription( schedule ); + Assert.StartsWith( "Daily", desc ); + } + + [TestMethod] + public void GetFriendlyDescription_EveryThreeDays_ReturnsIntervalDescription( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( s_testDate, TimeZoneInfo.Utc ), + DailyRecurrence = new() { DayInterval = 3 } + }; + string desc = ScheduleDescriptionBuilder.GetFriendlyDescription( schedule ); + Assert.StartsWith( "Every 3 days", desc ); + } + + [TestMethod] + public void GetFriendlyDescription_WeeklyMWF_ReturnsWeeklyWithDays( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( s_testDate, TimeZoneInfo.Utc ), + WeeklyRecurrence = new() { + WeekInterval = 1, + DaysOfWeek = DaysOfWeek.Monday | DaysOfWeek.Wednesday | DaysOfWeek.Friday + } + }; + string desc = ScheduleDescriptionBuilder.GetFriendlyDescription( schedule ); + Assert.StartsWith( "Weekly on", desc ); + Assert.Contains( "Mon", desc ); + Assert.Contains( "Wed", desc ); + Assert.Contains( "Fri", desc ); + } + + [TestMethod] + public void GetFriendlyDescription_BiWeekly_ReturnsEveryTwoWeeks( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( s_testDate, TimeZoneInfo.Utc ), + WeeklyRecurrence = new() { + WeekInterval = 2, + DaysOfWeek = DaysOfWeek.Monday + } + }; + string desc = ScheduleDescriptionBuilder.GetFriendlyDescription( schedule ); + Assert.StartsWith( "Every 2 weeks on", desc ); + } + + [TestMethod] + public void GetFriendlyDescription_MonthlyDayNumbers_ReturnsDayNumDescription( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( s_testDate, TimeZoneInfo.Utc ), + MonthlyRecurrence = new() { + MonthsOfYear = MonthsOfYear.January | MonthsOfYear.March, + DayNumbers = [1, 15] + } + }; + string desc = ScheduleDescriptionBuilder.GetFriendlyDescription( schedule ); + Assert.StartsWith( "Monthly on the", desc ); + Assert.Contains( "1st", desc ); + Assert.Contains( "15th", desc ); + } + + [TestMethod] + public void GetFriendlyDescription_MonthlyWeekBased_ReturnsWeekDayDescription( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( s_testDate, TimeZoneInfo.Utc ), + MonthlyRecurrence = new() { + MonthsOfYear = MonthsOfYear.January, + WeekNumber = WeekNumberWithinMonth.First, + DaysOfWeek = DaysOfWeek.Monday + } + }; + string desc = ScheduleDescriptionBuilder.GetFriendlyDescription( schedule ); + Assert.StartsWith( "Monthly on the", desc ); + Assert.Contains( "Mon", desc ); + } + + [TestMethod] + public void GetFriendlyDescription_WithRepeatOptions_ContainsRepeating( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( s_testDate, TimeZoneInfo.Utc ), + RepeatOptions = new() { RepeatIntervalMinutes = 15, RepeatDurationMinutes = 120 } + }; + string desc = ScheduleDescriptionBuilder.GetFriendlyDescription( schedule ); + Assert.Contains( "repeating every 15 min", desc ); + Assert.Contains( "for 2 hours", desc ); + } + + [TestMethod] + public void GetFriendlyDescription_WithExpiration_ContainsUntil( ) { + DateTime expDate = new( 2025, 12, 31, 17, 0, 0, DateTimeKind.Utc ); + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( s_testDate, TimeZoneInfo.Utc ), + Expiration = MakeExpiration( expDate, TimeZoneInfo.Utc ) + }; + string desc = ScheduleDescriptionBuilder.GetFriendlyDescription( schedule ); + Assert.Contains( "until 2025-12-31", desc ); + } + + [TestMethod] + public void GetFriendlyDescription_RepeatIndefinitely_ContainsIndefinitely( ) { + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( s_testDate, TimeZoneInfo.Utc ), + RepeatOptions = new() { RepeatIntervalMinutes = 60, RepeatDurationMinutes = -1 } + }; + string desc = ScheduleDescriptionBuilder.GetFriendlyDescription( schedule ); + Assert.Contains( "indefinitely", desc ); + } + +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleServiceTests.cs new file mode 100644 index 0000000..47f03d8 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleServiceTests.cs @@ -0,0 +1,432 @@ +using System.ComponentModel.DataAnnotations; + +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Core.Scheduling; +using Werkr.Data; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Models; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Tests.Data.Unit.Scheduling; + +[TestClass] +public class ScheduleServiceTests { + private SqliteConnection _connection = null!; + private SqliteWerkrDbContext _dbContext = null!; + private ScheduleService _service = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + + _service = new ScheduleService( + _dbContext, + new HolidayDateService( _dbContext, NullLogger.Instance ), + NullLogger.Instance ); + } + + [TestCleanup] + public void TestCleanup( ) { + _dbContext?.Dispose( ); + _connection?.Dispose( ); + } + + #region Helpers + + private static Schedule MakeMinimalSchedule( string name = "Test Schedule" ) => new( ) { + DbSchedule = new DbSchedule { Name = name, StopTaskAfterMinutes = 30 }, + StartDateTime = new StartDateTimeInfo { + Date = new DateOnly( 2025, 6, 15 ), + Time = new TimeOnly( 9, 0 ), + TimeZone = TimeZoneInfo.Utc, + }, + }; + + private static Schedule MakeDailySchedule( string name = "Daily Schedule" ) { + Schedule s = MakeMinimalSchedule( name ); + s.DailyRecurrence = new DailyRecurrence { DayInterval = 2 }; + return s; + } + + private static Schedule MakeWeeklySchedule( string name = "Weekly Schedule" ) { + Schedule s = MakeMinimalSchedule( name ); + s.WeeklyRecurrence = new WeeklyRecurrence { + WeekInterval = 1, + DaysOfWeek = DaysOfWeek.Monday | DaysOfWeek.Wednesday | DaysOfWeek.Friday, + }; + return s; + } + + private static Schedule MakeMonthlyDayNumSchedule( string name = "Monthly DayNum Schedule" ) { + Schedule s = MakeMinimalSchedule( name ); + s.MonthlyRecurrence = new MonthlyRecurrence { + DayNumbers = [1, 15], + MonthsOfYear = MonthsOfYear.January | MonthsOfYear.July, + }; + return s; + } + + private static Schedule MakeMonthlyWeekDaySchedule( string name = "Monthly WeekDay Schedule" ) { + Schedule s = MakeMinimalSchedule( name ); + s.MonthlyRecurrence = new MonthlyRecurrence { + WeekNumber = WeekNumberWithinMonth.Second, + DaysOfWeek = DaysOfWeek.Tuesday, + MonthsOfYear = MonthsOfYear.March | MonthsOfYear.September, + }; + return s; + } + + private static Schedule MakeFullSchedule( string name = "Full Schedule" ) { + Schedule s = MakeDailySchedule( name ); + s.Expiration = new ExpirationDateTimeInfo { + Date = new DateOnly( 2026, 12, 31 ), + Time = new TimeOnly( 23, 59 ), + TimeZone = TimeZoneInfo.Utc, + }; + s.RepeatOptions = new ScheduleRepeatOptions { + RepeatIntervalMinutes = 60, + RepeatDurationMinutes = 480, + }; + return s; + } + + #endregion Helpers + + #region CreateAsync + + [TestMethod] + public async Task CreateAsync_MinimalSchedule_PersistsAndReturns( ) { + Schedule created = await _service.CreateAsync( MakeMinimalSchedule( ), TestContext.CancellationToken ); + + Assert.IsNotNull( created ); + Assert.AreNotEqual( Guid.Empty, created.DbSchedule.Id ); + Assert.AreEqual( "Test Schedule", created.DbSchedule.Name ); + Assert.IsNotNull( created.StartDateTime ); + Assert.AreEqual( new DateOnly( 2025, 6, 15 ), created.StartDateTime!.Date ); + } + + [TestMethod] + public async Task CreateAsync_WithDailyRecurrence_PersistsRecurrence( ) { + Schedule created = await _service.CreateAsync( MakeDailySchedule( ), TestContext.CancellationToken ); + + Assert.IsNotNull( created.DailyRecurrence ); + Assert.AreEqual( 2, created.DailyRecurrence!.DayInterval ); + Assert.IsNull( created.WeeklyRecurrence ); + Assert.IsNull( created.MonthlyRecurrence ); + } + + [TestMethod] + public async Task CreateAsync_WithWeeklyRecurrence_PersistsRecurrence( ) { + Schedule created = await _service.CreateAsync( MakeWeeklySchedule( ), TestContext.CancellationToken ); + + Assert.IsNotNull( created.WeeklyRecurrence ); + Assert.AreEqual( 1, created.WeeklyRecurrence!.WeekInterval ); + Assert.AreEqual( + DaysOfWeek.Monday | DaysOfWeek.Wednesday | DaysOfWeek.Friday, + created.WeeklyRecurrence.DaysOfWeek ); + } + + [TestMethod] + public async Task CreateAsync_WithMonthlyDayNum_PersistsRecurrence( ) { + Schedule created = await _service.CreateAsync( MakeMonthlyDayNumSchedule( ), TestContext.CancellationToken ); + + Assert.IsNotNull( created.MonthlyRecurrence ); + CollectionAssert.AreEqual( new[] { 1, 15 }, created.MonthlyRecurrence!.DayNumbers ); + Assert.AreEqual( + MonthsOfYear.January | MonthsOfYear.July, + created.MonthlyRecurrence.MonthsOfYear ); + } + + [TestMethod] + public async Task CreateAsync_WithMonthlyWeekDay_PersistsRecurrence( ) { + Schedule created = await _service.CreateAsync( MakeMonthlyWeekDaySchedule( ), TestContext.CancellationToken ); + + Assert.IsNotNull( created.MonthlyRecurrence ); + Assert.AreEqual( WeekNumberWithinMonth.Second, created.MonthlyRecurrence!.WeekNumber ); + Assert.AreEqual( DaysOfWeek.Tuesday, created.MonthlyRecurrence.DaysOfWeek ); + } + + [TestMethod] + public async Task CreateAsync_FullSchedule_PersistsAllSubEntities( ) { + Schedule created = await _service.CreateAsync( MakeFullSchedule( ), TestContext.CancellationToken ); + + Assert.IsNotNull( created.StartDateTime ); + Assert.IsNotNull( created.Expiration ); + Assert.IsNotNull( created.RepeatOptions ); + Assert.IsNotNull( created.DailyRecurrence ); + Assert.AreEqual( 60, created.RepeatOptions!.RepeatIntervalMinutes ); + Assert.AreEqual( 480, created.RepeatOptions.RepeatDurationMinutes ); + } + + [TestMethod] + public async Task CreateAsync_NoStartDateTime_ThrowsValidationException( ) { + Schedule schedule = new( ) { + DbSchedule = new DbSchedule { Name = "Invalid" }, + }; + + _ = await Assert.ThrowsExactlyAsync( ( ) => + _service.CreateAsync( schedule, TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task CreateAsync_MultipleRecurrenceTypes_ThrowsValidationException( ) { + Schedule schedule = MakeDailySchedule( "Bad" ); + schedule.WeeklyRecurrence = new WeeklyRecurrence { + WeekInterval = 1, + DaysOfWeek = DaysOfWeek.Monday, + }; + + _ = await Assert.ThrowsExactlyAsync( ( ) => + _service.CreateAsync( schedule, TestContext.CancellationToken ) ); + } + + #endregion CreateAsync + + #region GetByIdAsync + + [TestMethod] + public async Task GetByIdAsync_ExistingSchedule_Returns( ) { + Schedule created = await _service.CreateAsync( MakeDailySchedule( ), TestContext.CancellationToken ); + Guid id = created.DbSchedule.Id; + + Schedule? retrieved = await _service.GetByIdAsync( id, TestContext.CancellationToken ); + + Assert.IsNotNull( retrieved ); + Assert.AreEqual( id, retrieved!.DbSchedule.Id ); + Assert.AreEqual( "Daily Schedule", retrieved.DbSchedule.Name ); + } + + [TestMethod] + public async Task GetByIdAsync_NonExistentId_ReturnsNull( ) { + Schedule? result = await _service.GetByIdAsync( Guid.NewGuid( ), TestContext.CancellationToken ); + Assert.IsNull( result ); + } + + #endregion GetByIdAsync + + #region GetByNameAsync + + [TestMethod] + public async Task GetByNameAsync_ExistingName_Returns( ) { + _ = await _service.CreateAsync( MakeMinimalSchedule( "FindMe" ), TestContext.CancellationToken ); + + Schedule? found = await _service.GetByNameAsync( "FindMe", TestContext.CancellationToken ); + + Assert.IsNotNull( found ); + Assert.AreEqual( "FindMe", found!.DbSchedule.Name ); + } + + [TestMethod] + public async Task GetByNameAsync_NonExistentName_ReturnsNull( ) { + Schedule? result = await _service.GetByNameAsync( "NoSuchName", TestContext.CancellationToken ); + Assert.IsNull( result ); + } + + #endregion GetByNameAsync + + #region GetAllAsync + + [TestMethod] + public async Task GetAllAsync_Empty_ReturnsEmptyList( ) { + IReadOnlyList result = await _service.GetAllAsync( TestContext.CancellationToken ); + Assert.HasCount( 0, result ); + } + + [TestMethod] + public async Task GetAllAsync_MultipleSchedules_ReturnsAll( ) { + _ = await _service.CreateAsync( MakeMinimalSchedule( "A" ), TestContext.CancellationToken ); + _ = await _service.CreateAsync( MakeDailySchedule( "B" ), TestContext.CancellationToken ); + _ = await _service.CreateAsync( MakeWeeklySchedule( "C" ), TestContext.CancellationToken ); + + IReadOnlyList all = await _service.GetAllAsync( TestContext.CancellationToken ); + Assert.HasCount( 3, all ); + } + + #endregion GetAllAsync + + #region UpdateAsync + + [TestMethod] + public async Task UpdateAsync_UpdateCoreName_Reflects( ) { + Schedule created = await _service.CreateAsync( MakeMinimalSchedule( "OldName" ), TestContext.CancellationToken ); + Guid id = created.DbSchedule.Id; + + Schedule update = MakeMinimalSchedule( "NewName" ); + update.DbSchedule.Id = id; + + Schedule updated = await _service.UpdateAsync( update, TestContext.CancellationToken ); + Assert.AreEqual( "NewName", updated.DbSchedule.Name ); + } + + [TestMethod] + public async Task UpdateAsync_AddRecurrence_PersistsNewRecurrence( ) { + Schedule created = await _service.CreateAsync( MakeMinimalSchedule( ), TestContext.CancellationToken ); + Guid id = created.DbSchedule.Id; + + Schedule update = MakeDailySchedule( "Test Schedule" ); + update.DbSchedule.Id = id; + + Schedule updated = await _service.UpdateAsync( update, TestContext.CancellationToken ); + Assert.IsNotNull( updated.DailyRecurrence ); + Assert.AreEqual( 2, updated.DailyRecurrence!.DayInterval ); + } + + [TestMethod] + public async Task UpdateAsync_ChangeRecurrenceType_RemovesOldAddsNew( ) { + Schedule daily = await _service.CreateAsync( MakeDailySchedule( ), TestContext.CancellationToken ); + Guid id = daily.DbSchedule.Id; + + Schedule update = MakeWeeklySchedule( "Daily Schedule" ); + update.DbSchedule.Id = id; + + Schedule updated = await _service.UpdateAsync( update, TestContext.CancellationToken ); + Assert.IsNull( updated.DailyRecurrence ); + Assert.IsNotNull( updated.WeeklyRecurrence ); + } + + [TestMethod] + public async Task UpdateAsync_AddExpiration_PersistsExpiration( ) { + Schedule created = await _service.CreateAsync( MakeMinimalSchedule( ), TestContext.CancellationToken ); + Guid id = created.DbSchedule.Id; + Assert.IsNull( created.Expiration ); + + Schedule update = MakeMinimalSchedule( ); + update.DbSchedule.Id = id; + update.Expiration = new ExpirationDateTimeInfo { + Date = new DateOnly( 2026, 1, 1 ), + Time = new TimeOnly( 0, 0 ), + TimeZone = TimeZoneInfo.Utc, + }; + + Schedule updated = await _service.UpdateAsync( update, TestContext.CancellationToken ); + Assert.IsNotNull( updated.Expiration ); + Assert.AreEqual( new DateOnly( 2026, 1, 1 ), updated.Expiration!.Date ); + } + + [TestMethod] + public async Task UpdateAsync_RemoveExpiration_RemovesExpiration( ) { + Schedule created = await _service.CreateAsync( MakeFullSchedule( ), TestContext.CancellationToken ); + Guid id = created.DbSchedule.Id; + Assert.IsNotNull( created.Expiration ); + + // Remove expiration by not including it + Schedule update = MakeDailySchedule( "Full Schedule" ); + update.DbSchedule.Id = id; + update.RepeatOptions = new ScheduleRepeatOptions { + RepeatIntervalMinutes = 60, + RepeatDurationMinutes = 480, + }; + + Schedule updated = await _service.UpdateAsync( update, TestContext.CancellationToken ); + Assert.IsNull( updated.Expiration ); + } + + [TestMethod] + public async Task UpdateAsync_NonExistentId_ThrowsKeyNotFoundException( ) { + Schedule schedule = MakeMinimalSchedule( ); + schedule.DbSchedule.Id = Guid.NewGuid( ); + + _ = await Assert.ThrowsExactlyAsync( ( ) => + _service.UpdateAsync( schedule, TestContext.CancellationToken ) ); + } + + #endregion UpdateAsync + + #region DeleteAsync + + [TestMethod] + public async Task DeleteAsync_ExistingSchedule_RemovesAllSubEntities( ) { + Schedule created = await _service.CreateAsync( MakeFullSchedule( ), TestContext.CancellationToken ); + Guid id = created.DbSchedule.Id; + + await _service.DeleteAsync( id, TestContext.CancellationToken ); + + Assert.IsNull( await _service.GetByIdAsync( id, TestContext.CancellationToken ) ); + Assert.HasCount( 0, await _dbContext.Schedules.ToListAsync( TestContext.CancellationToken ) ); + Assert.HasCount( 0, await _dbContext.StartDateTimeInfos.ToListAsync( TestContext.CancellationToken ) ); + Assert.HasCount( 0, await _dbContext.ExpirationDateTimeInfos.ToListAsync( TestContext.CancellationToken ) ); + Assert.HasCount( 0, await _dbContext.ScheduleRepeatOptions.ToListAsync( TestContext.CancellationToken ) ); + Assert.HasCount( 0, await _dbContext.DailyRecurrences.ToListAsync( TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task DeleteAsync_NonExistentId_ThrowsKeyNotFoundException( ) { + _ = await Assert.ThrowsExactlyAsync( ( ) => + _service.DeleteAsync( Guid.NewGuid( ), TestContext.CancellationToken ) ); + } + + #endregion DeleteAsync + + #region PreviewOccurrencesAsync + + [TestMethod] + public async Task PreviewOccurrencesAsync_DailySchedule_ReturnsOccurrences( ) { + Schedule created = await _service.CreateAsync( MakeDailySchedule( ), TestContext.CancellationToken ); + Guid id = created.DbSchedule.Id; + + DateTime windowEnd = new( 2025, 7, 15, 23, 59, 59, DateTimeKind.Utc ); + ScheduleOccurrenceResult result = await _service.PreviewOccurrencesAsync( + id, windowEnd, TestContext.CancellationToken ); + + Assert.IsNotEmpty( result.Occurrences ); + } + + [TestMethod] + public async Task PreviewOccurrencesAsync_NonExistentId_ThrowsKeyNotFoundException( ) { + _ = await Assert.ThrowsExactlyAsync( ( ) => + _service.PreviewOccurrencesAsync( + Guid.NewGuid( ), + DateTime.UtcNow.AddDays( 30 ), + TestContext.CancellationToken ) ); + } + + #endregion PreviewOccurrencesAsync + + #region RoundTrip + + [TestMethod] + public async Task RoundTrip_CreateReadUpdateDelete_Succeeds( ) { + // Create + Schedule created = await _service.CreateAsync( MakeDailySchedule( "RoundTrip" ), TestContext.CancellationToken ); + Guid id = created.DbSchedule.Id; + Assert.AreEqual( "RoundTrip", created.DbSchedule.Name ); + Assert.AreEqual( 2, created.DailyRecurrence!.DayInterval ); + + // Read + Schedule? read = await _service.GetByIdAsync( id, TestContext.CancellationToken ); + Assert.IsNotNull( read ); + Assert.AreEqual( id, read!.DbSchedule.Id ); + + // Update — change name + add expiration + Schedule update = MakeDailySchedule( "RoundTripUpdated" ); + update.DbSchedule.Id = id; + update.Expiration = new ExpirationDateTimeInfo { + Date = new DateOnly( 2026, 6, 15 ), + Time = new TimeOnly( 17, 0 ), + TimeZone = TimeZoneInfo.Utc, + }; + + Schedule updated = await _service.UpdateAsync( update, TestContext.CancellationToken ); + Assert.AreEqual( "RoundTripUpdated", updated.DbSchedule.Name ); + Assert.IsNotNull( updated.Expiration ); + + // Delete + await _service.DeleteAsync( id, TestContext.CancellationToken ); + Assert.IsNull( await _service.GetByIdAsync( id, TestContext.CancellationToken ) ); + } + + #endregion RoundTrip +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Security/SecretStoreTests.cs b/src/Test/Werkr.Tests.Data/Unit/Security/SecretStoreTests.cs new file mode 100644 index 0000000..e07ccc0 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Security/SecretStoreTests.cs @@ -0,0 +1,46 @@ +using Werkr.Core.Security; + +namespace Werkr.Tests.Data.Unit.Security; + +[TestClass] +public class SecretStoreTests { + [TestMethod] + [OSCondition( OperatingSystems.Windows )] + public async Task SetAndGetSecret_RoundTrip_ReturnsStoredValue( ) { + ISecretStore store = SecretStoreFactory.Create( ); + string key = "werkr_test_" + Guid.NewGuid( ).ToString( "N" ); + string value = "test-secret-" + Guid.NewGuid( ).ToString( ); + + try { + await store.SetSecretAsync( key, value ); + string? retrieved = await store.GetSecretAsync( key ); + Assert.AreEqual( value, retrieved ); + } finally { + await store.DeleteSecretAsync( key ); + } + } + + [TestMethod] + [OSCondition( OperatingSystems.Windows )] + public async Task GetSecret_NonExistentKey_ReturnsNull( ) { + ISecretStore store = SecretStoreFactory.Create( ); + string key = "werkr_test_nonexistent_" + Guid.NewGuid( ).ToString( "N" ); + + string? result = await store.GetSecretAsync( key ); + + Assert.IsNull( result ); + } + + [TestMethod] + [OSCondition( OperatingSystems.Windows )] + public async Task DeleteSecret_RemovesStoredValue( ) { + ISecretStore store = SecretStoreFactory.Create( ); + string key = "werkr_test_" + Guid.NewGuid( ).ToString( "N" ); + + await store.SetSecretAsync( key, "to-be-deleted" ); + await store.DeleteSecretAsync( key ); + string? result = await store.GetSecretAsync( key ); + + Assert.IsNull( result ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Tasks/AgentResolverTests.cs b/src/Test/Werkr.Tests.Data/Unit/Tasks/AgentResolverTests.cs new file mode 100644 index 0000000..7786217 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Tasks/AgentResolverTests.cs @@ -0,0 +1,151 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Common.Models; +using Werkr.Core.Communication; +using Werkr.Core.Tasks; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Tests.Data.Unit.Tasks; + +[TestClass] +public class AgentResolverTests { + private SqliteConnection _connection = null!; + private SqliteWerkrDbContext _dbContext = null!; + private ServiceProvider _serviceProvider = null!; + private AgentConnectionManager _connectionManager = null!; + private AgentResolver _resolver = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + + ServiceCollection services = new( ); + _ = services.AddDbContext( + b => b.UseSqlite( _connection ), + ServiceLifetime.Scoped ); + _ = services.AddDbContext( + b => b.UseSqlite( _connection ), + ServiceLifetime.Scoped ); + _serviceProvider = services.BuildServiceProvider( ); + + _connectionManager = new AgentConnectionManager( + _serviceProvider.GetRequiredService( ), + NullLogger.Instance ); + + _resolver = new AgentResolver( _dbContext, _connectionManager, NullLogger.Instance ); + } + + [TestCleanup] + public void TestCleanup( ) { + _connectionManager?.Dispose( ); + _serviceProvider?.Dispose( ); + _dbContext?.Dispose( ); + _connection?.Dispose( ); + } + + private RegisteredConnection MakeConnection( + string name, ConnectionStatus status, params string[] tags ) { + return new RegisteredConnection { + ConnectionName = name, + RemoteUrl = $"https://{name}.test:5100", + Status = status, + Tags = tags, + IsServer = true, + SharedKey = new byte[32], + LocalPublicKey = System.Security.Cryptography.RSA.Create( 2048 ).ExportParameters( false ), + RemotePublicKey = System.Security.Cryptography.RSA.Create( 2048 ).ExportParameters( false ), + }; + } + + [TestMethod] + public async Task Resolve_ReturnsNull_WhenNoAgents( ) { + RegisteredConnection? result = await _resolver.ResolveAsync( + ["linux"], TestContext.CancellationToken ); + Assert.IsNull( result ); + } + + [TestMethod] + public async Task Resolve_ReturnsNull_WhenEmptyTags( ) { + RegisteredConnection? result = await _resolver.ResolveAsync( + [], TestContext.CancellationToken ); + Assert.IsNull( result ); + } + + [TestMethod] + public async Task Resolve_ReturnsNull_WhenNoTagMatch( ) { + _ = _dbContext.RegisteredConnections.Add( MakeConnection( "agent1", ConnectionStatus.Connected, "windows" ) ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + RegisteredConnection? result = await _resolver.ResolveAsync( + ["linux"], TestContext.CancellationToken ); + Assert.IsNull( result ); + } + + [TestMethod] + public async Task Resolve_ReturnsMatch_WhenTagsIntersect( ) { + _ = _dbContext.RegisteredConnections.Add( MakeConnection( "agent1", ConnectionStatus.Connected, "linux", "docker" ) ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + RegisteredConnection? result = await _resolver.ResolveAsync( + ["linux"], TestContext.CancellationToken ); + Assert.IsNotNull( result ); + Assert.AreEqual( "agent1", result.ConnectionName ); + } + + [TestMethod] + public async Task Resolve_CaseInsensitive( ) { + _ = _dbContext.RegisteredConnections.Add( MakeConnection( "agent1", ConnectionStatus.Connected, "Linux" ) ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + RegisteredConnection? result = await _resolver.ResolveAsync( + ["LINUX"], TestContext.CancellationToken ); + Assert.IsNotNull( result ); + } + + [TestMethod] + public async Task Resolve_IgnoresDisconnectedAgents( ) { + _ = _dbContext.RegisteredConnections.Add( MakeConnection( "disconnected", ConnectionStatus.Disconnected, "linux" ) ); + _ = _dbContext.RegisteredConnections.Add( MakeConnection( "revoked", ConnectionStatus.Revoked, "linux" ) ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + RegisteredConnection? result = await _resolver.ResolveAsync( + ["linux"], TestContext.CancellationToken ); + Assert.IsNull( result ); + } + + [TestMethod] + public async Task ResolveAll_ReturnsMultipleMatches( ) { + _ = _dbContext.RegisteredConnections.Add( MakeConnection( "agent1", ConnectionStatus.Connected, "linux" ) ); + _ = _dbContext.RegisteredConnections.Add( MakeConnection( "agent2", ConnectionStatus.Connected, "linux", "docker" ) ); + _ = _dbContext.RegisteredConnections.Add( MakeConnection( "agent3", ConnectionStatus.Connected, "windows" ) ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + IReadOnlyList results = await _resolver.ResolveAllAsync( + ["linux"], TestContext.CancellationToken ); + Assert.HasCount( 2, results ); + } + + [TestMethod] + public async Task ResolveAll_EmptyTags_ReturnsEmpty( ) { + _ = _dbContext.RegisteredConnections.Add( MakeConnection( "agent1", ConnectionStatus.Connected, "linux" ) ); + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + IReadOnlyList results = await _resolver.ResolveAllAsync( + [], TestContext.CancellationToken ); + Assert.IsEmpty( results ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Tasks/SuccessCriteriaEvaluatorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Tasks/SuccessCriteriaEvaluatorTests.cs new file mode 100644 index 0000000..8ae8a81 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Tasks/SuccessCriteriaEvaluatorTests.cs @@ -0,0 +1,234 @@ +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Core.Communication; +using Werkr.Core.Tasks; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Tests.Data.Unit.Tasks; + +[TestClass] +public class SuccessCriteriaEvaluatorTests { + private SuccessCriteriaEvaluator _evaluator = null!; + + [TestInitialize] + public void TestInit( ) { + _evaluator = new SuccessCriteriaEvaluator( NullLogger.Instance ); + } + + // ── Default Criteria ── + + [TestMethod] + public void DefaultShellCommand_ExitCodeZero_Succeeds( ) { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, null, exitCode: 0, output: [], exception: null ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void DefaultShellCommand_ExitCodeNonZero_Fails( ) { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, null, exitCode: 1, output: [], exception: null ); + Assert.IsFalse( result ); + } + + [TestMethod] + public void DefaultShellCommand_NullExitCode_Fails( ) { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, null, exitCode: null, output: [], exception: null ); + Assert.IsFalse( result ); + } + + [TestMethod] + public void DefaultPwshCommand_NoErrors_Succeeds( ) { + List output = [ + OperatorOutput.Create( "Information", "Hello World" ), + ]; + bool result = _evaluator.Evaluate( + TaskActionType.PowerShellCommand, null, exitCode: null, output: output, exception: null ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void DefaultPwshCommand_WithErrorOutput_Fails( ) { + List output = [ + OperatorOutput.Create( "Information", "Starting..." ), + OperatorOutput.Create( "Error", "Something went wrong" ), + ]; + bool result = _evaluator.Evaluate( + TaskActionType.PowerShellCommand, null, exitCode: null, output: output, exception: null ); + Assert.IsFalse( result ); + } + + [TestMethod] + public void DefaultAction_AlwaysSucceeds( ) { + bool result = _evaluator.Evaluate( + TaskActionType.Action, null, exitCode: null, output: [], exception: null ); + Assert.IsTrue( result ); + } + + // ── Exception Handling ── + + [TestMethod] + public void Exception_AlwaysFails_UnlessCriteriaIsAlways( ) { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, null, exitCode: 0, output: [], + exception: new InvalidOperationException( "test" ) ); + Assert.IsFalse( result ); + } + + [TestMethod] + public void Exception_WithAlwaysCriteria_Succeeds( ) { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, "always", exitCode: null, output: [], + exception: new InvalidOperationException( "test" ) ); + Assert.IsTrue( result ); + } + + // ── Explicit Criteria: exitCode == 0 ── + + [TestMethod] + public void ExitCodeCriteria_Zero_Succeeds( ) { + bool result = _evaluator.Evaluate( + TaskActionType.PowerShellCommand, "exitCode == 0", exitCode: 0, output: [], exception: null ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void ExitCodeCriteria_NonZero_Fails( ) { + bool result = _evaluator.Evaluate( + TaskActionType.PowerShellCommand, "exitCode == 0", exitCode: 42, output: [], exception: null ); + Assert.IsFalse( result ); + } + + [TestMethod] + public void ExitCodeCriteria_Null_Fails( ) { + bool result = _evaluator.Evaluate( + TaskActionType.PowerShellCommand, "exitCode == 0", exitCode: null, output: [], exception: null ); + Assert.IsFalse( result ); + } + + // ── Explicit Criteria: pwsh.HadErrors == false ── + + [TestMethod] + public void PwshHadErrorsCriteria_NoErrors_Succeeds( ) { + List output = [ + OperatorOutput.Create( "Information", "OK" ), + ]; + bool result = _evaluator.Evaluate( + TaskActionType.PowerShellCommand, "pwsh.HadErrors == false", + exitCode: null, output: output, exception: null ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void PwshHadErrorsCriteria_WithErrors_Fails( ) { + List output = [ + OperatorOutput.Create( "Error", "bad" ), + ]; + bool result = _evaluator.Evaluate( + TaskActionType.PowerShellCommand, "pwsh.HadErrors == false", + exitCode: null, output: output, exception: null ); + Assert.IsFalse( result ); + } + + // ── Explicit Criteria: output.contains ── + + [TestMethod] + public void OutputContainsCriteria_Found_Succeeds( ) { + List output = [ + OperatorOutput.Create( "Information", "Build succeeded" ), + ]; + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, "output.contains(\"Build succeeded\")", + exitCode: null, output: output, exception: null ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void OutputContainsCriteria_NotFound_Fails( ) { + List output = [ + OperatorOutput.Create( "Information", "Build failed" ), + ]; + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, "output.contains(\"Build succeeded\")", + exitCode: null, output: output, exception: null ); + Assert.IsFalse( result ); + } + + [TestMethod] + public void OutputContainsCriteria_CaseInsensitive( ) { + List output = [ + OperatorOutput.Create( "Information", "BUILD SUCCEEDED" ), + ]; + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, "output.contains(\"build succeeded\")", + exitCode: null, output: output, exception: null ); + Assert.IsTrue( result ); + } + + // ── Explicit Criteria: always ── + + [TestMethod] + public void AlwaysCriteria_Succeeds( ) { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, "always", exitCode: 99, output: [], exception: null ); + Assert.IsTrue( result ); + } + + // ── Unknown Criteria ── + + [TestMethod] + public void UnknownCriteria_NullExitCode_Succeeds( ) { + // Unknown criteria falls back to exitCode is null or 0 + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, "someUnknownExpression", + exitCode: null, output: [], exception: null ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void UnknownCriteria_ZeroExitCode_Succeeds( ) { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, "someUnknownExpression", + exitCode: 0, output: [], exception: null ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void UnknownCriteria_NonZeroExitCode_Fails( ) { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, "someUnknownExpression", + exitCode: 1, output: [], exception: null ); + Assert.IsFalse( result ); + } + + // ── DescribeEffectiveCriteria ── + + [TestMethod] + public void DescribeEffective_ExplicitCriteria_ReturnsCriteria( ) { + string result = SuccessCriteriaEvaluator.DescribeEffectiveCriteria( + TaskActionType.ShellCommand, "exitCode == 0" ); + Assert.AreEqual( "exitCode == 0", result ); + } + + [TestMethod] + public void DescribeEffective_ShellDefault_ReturnsExitCodeDefault( ) { + string result = SuccessCriteriaEvaluator.DescribeEffectiveCriteria( + TaskActionType.ShellCommand, null ); + Assert.Contains( "exitCode == 0", result ); + } + + [TestMethod] + public void DescribeEffective_PwshDefault_ReturnsHadErrorsDefault( ) { + string result = SuccessCriteriaEvaluator.DescribeEffectiveCriteria( + TaskActionType.PowerShellCommand, null ); + Assert.Contains( "pwsh.HadErrors == false", result ); + } + + [TestMethod] + public void DescribeEffective_ActionDefault_ReturnsAlways( ) { + string result = SuccessCriteriaEvaluator.DescribeEffectiveCriteria( + TaskActionType.Action, null ); + Assert.Contains( "always", result ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Tasks/TaskServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Tasks/TaskServiceTests.cs new file mode 100644 index 0000000..cf781c1 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Tasks/TaskServiceTests.cs @@ -0,0 +1,209 @@ +using System.ComponentModel.DataAnnotations; + +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Core.Tasks; +using Werkr.Data; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Tests.Data.Unit.Tasks; + +[TestClass] +public class TaskServiceTests { + private SqliteConnection _connection = null!; + private SqliteWerkrDbContext _dbContext = null!; + private TaskService _service = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + + _service = new TaskService( _dbContext, NullLogger.Instance ); + } + + [TestCleanup] + public void TestCleanup( ) { + _dbContext?.Dispose( ); + _connection?.Dispose( ); + } + + private static WerkrTask MakeTask( string name = "Test Task" ) => new( ) { + Name = name, + ActionType = TaskActionType.ShellCommand, + Content = "echo hello", + TargetTags = ["linux"], + }; + + // ── Create ── + + [TestMethod] + public async Task Create_SetsIdAndSyncInterval( ) { + WerkrTask task = MakeTask( ); + WerkrTask created = await _service.CreateAsync( task, TestContext.CancellationToken ); + + Assert.IsGreaterThan( 0L, created.Id ); + Assert.IsTrue( created.SyncIntervalMinutes is >= 30 and <= 60 ); + } + + [TestMethod] + public async Task Create_PersistsInDatabase( ) { + WerkrTask task = MakeTask( ); + WerkrTask created = await _service.CreateAsync( task, TestContext.CancellationToken ); + + WerkrTask? fromDb = await _dbContext.Tasks.FirstOrDefaultAsync( + t => t.Id == created.Id, TestContext.CancellationToken ); + Assert.IsNotNull( fromDb ); + Assert.AreEqual( "Test Task", fromDb.Name ); + } + + [TestMethod] + public async Task Create_RequiresName( ) { + WerkrTask task = MakeTask( ); + task.Name = ""; + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.CreateAsync( task, TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task Create_RequiresContent( ) { + WerkrTask task = MakeTask( ); + task.Content = ""; + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.CreateAsync( task, TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task Create_RequiresTargetTags( ) { + WerkrTask task = MakeTask( ); + task.TargetTags = []; + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.CreateAsync( task, TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task Create_RejectsInvalidActionType( ) { + WerkrTask task = MakeTask( ); + task.ActionType = (TaskActionType)999; + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.CreateAsync( task, TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task Create_RejectsNegativeTimeout( ) { + WerkrTask task = MakeTask( ); + task.TimeoutMinutes = -5; + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.CreateAsync( task, TestContext.CancellationToken ) ); + } + + // ── GetAll ── + + [TestMethod] + public async Task GetAll_ReturnsEmpty_WhenNoTasks( ) { + IReadOnlyList tasks = await _service.GetAllAsync( ct: TestContext.CancellationToken ); + Assert.IsEmpty( tasks ); + } + + [TestMethod] + public async Task GetAll_ReturnsCreatedTasks( ) { + _ = await _service.CreateAsync( MakeTask( "A" ), TestContext.CancellationToken ); + _ = await _service.CreateAsync( MakeTask( "B" ), TestContext.CancellationToken ); + + IReadOnlyList tasks = await _service.GetAllAsync( ct: TestContext.CancellationToken ); + Assert.HasCount( 2, tasks ); + } + + // ── GetById ── + + [TestMethod] + public async Task GetById_ReturnsTask( ) { + WerkrTask created = await _service.CreateAsync( MakeTask( ), TestContext.CancellationToken ); + + WerkrTask? found = await _service.GetByIdAsync( created.Id, TestContext.CancellationToken ); + Assert.IsNotNull( found ); + Assert.AreEqual( created.Id, found.Id ); + } + + [TestMethod] + public async Task GetById_ReturnsNull_WhenNotFound( ) { + WerkrTask? found = await _service.GetByIdAsync( 999, TestContext.CancellationToken ); + Assert.IsNull( found ); + } + + // ── Update ── + + [TestMethod] + public async Task Update_ChangesName( ) { + WerkrTask created = await _service.CreateAsync( MakeTask( ), TestContext.CancellationToken ); + + WerkrTask update = MakeTask( "Updated Name" ); + update.Id = created.Id; + WerkrTask updated = await _service.UpdateAsync( update, TestContext.CancellationToken ); + + Assert.AreEqual( "Updated Name", updated.Name ); + } + + [TestMethod] + public async Task Update_ThrowsKeyNotFound_WhenMissing( ) { + WerkrTask update = MakeTask( ); + update.Id = 999; + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.UpdateAsync( update, TestContext.CancellationToken ) ); + } + + // ── Delete ── + + [TestMethod] + public async Task Delete_RemovesTask( ) { + WerkrTask created = await _service.CreateAsync( MakeTask( ), TestContext.CancellationToken ); + await _service.DeleteAsync( created.Id, TestContext.CancellationToken ); + + WerkrTask? found = await _service.GetByIdAsync( created.Id, TestContext.CancellationToken ); + Assert.IsNull( found ); + } + + [TestMethod] + public async Task Delete_ThrowsKeyNotFound_WhenMissing( ) { + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.DeleteAsync( 999, TestContext.CancellationToken ) ); + } + + // ── SetEnabled ── + + [TestMethod] + public async Task SetEnabled_TogglesState( ) { + WerkrTask created = await _service.CreateAsync( MakeTask( ), TestContext.CancellationToken ); + Assert.IsTrue( created.Enabled ); + + await _service.SetEnabledAsync( created.Id, false, TestContext.CancellationToken ); + + WerkrTask? found = await _dbContext.Tasks.FirstOrDefaultAsync( + t => t.Id == created.Id, TestContext.CancellationToken ); + Assert.IsNotNull( found ); + Assert.IsFalse( found.Enabled ); + } + + [TestMethod] + public async Task SetEnabled_ThrowsKeyNotFound_WhenMissing( ) { + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.SetEnabledAsync( 999, false, TestContext.CancellationToken ) ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Workflows/ConditionEvaluatorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Workflows/ConditionEvaluatorTests.cs new file mode 100644 index 0000000..8fecb22 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Workflows/ConditionEvaluatorTests.cs @@ -0,0 +1,214 @@ +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Core.Workflows; +using Werkr.Data.Entities.Tasks; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Tests.Data.Unit.Workflows; + +[TestClass] +public class ConditionEvaluatorTests { + private ConditionEvaluator _evaluator = null!; + + [TestInitialize] + public void TestInit( ) { + _evaluator = new ConditionEvaluator( NullLogger.Instance ); + } + + // ── Evaluate: null/empty expressions ── + + [TestMethod] + public void Evaluate_NullExpression_ReturnsTrue( ) { + WerkrJob job = new( ) { Success = false, ExitCode = 1 }; + bool result = _evaluator.Evaluate( null, job ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void Evaluate_EmptyExpression_ReturnsTrue( ) { + WerkrJob job = new( ) { Success = false, ExitCode = 1 }; + bool result = _evaluator.Evaluate( "", job ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void Evaluate_WhitespaceExpression_ReturnsTrue( ) { + WerkrJob job = new( ) { Success = true, ExitCode = 0 }; + bool result = _evaluator.Evaluate( " ", job ); + Assert.IsTrue( result ); + } + + // ── Evaluate: $? -eq $true / $false ── + + [TestMethod] + public void Evaluate_SuccessEqualsTrue_WhenJobSucceeded_ReturnsTrue( ) { + WerkrJob job = new( ) { Success = true, ExitCode = 0 }; + bool result = _evaluator.Evaluate( "$? -eq $true", job ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void Evaluate_SuccessEqualsTrue_WhenJobFailed_ReturnsFalse( ) { + WerkrJob job = new( ) { Success = false, ExitCode = 1 }; + bool result = _evaluator.Evaluate( "$? -eq $true", job ); + Assert.IsFalse( result ); + } + + [TestMethod] + public void Evaluate_SuccessEqualsFalse_WhenJobFailed_ReturnsTrue( ) { + WerkrJob job = new( ) { Success = false, ExitCode = 1 }; + bool result = _evaluator.Evaluate( "$? -eq $false", job ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void Evaluate_SuccessEqualsFalse_WhenJobSucceeded_ReturnsFalse( ) { + WerkrJob job = new( ) { Success = true, ExitCode = 0 }; + bool result = _evaluator.Evaluate( "$? -eq $false", job ); + Assert.IsFalse( result ); + } + + [TestMethod] + public void Evaluate_SuccessExpression_CaseInsensitive( ) { + WerkrJob job = new( ) { Success = true, ExitCode = 0 }; + bool result = _evaluator.Evaluate( "$? -eq $TRUE", job ); + Assert.IsTrue( result ); + } + + // ── Evaluate: $exitCode comparisons ── + + [TestMethod] + public void Evaluate_ExitCodeEqualsZero_WhenZero_ReturnsTrue( ) { + WerkrJob job = new( ) { Success = true, ExitCode = 0 }; + bool result = _evaluator.Evaluate( "$exitCode == 0", job ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void Evaluate_ExitCodeEqualsZero_WhenNonZero_ReturnsFalse( ) { + WerkrJob job = new( ) { Success = false, ExitCode = 42 }; + bool result = _evaluator.Evaluate( "$exitCode == 0", job ); + Assert.IsFalse( result ); + } + + [TestMethod] + public void Evaluate_ExitCodeNotEqual_WhenDifferent_ReturnsTrue( ) { + WerkrJob job = new( ) { Success = false, ExitCode = 1 }; + bool result = _evaluator.Evaluate( "$exitCode != 0", job ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void Evaluate_ExitCodeGreaterThan_WhenGreater_ReturnsTrue( ) { + WerkrJob job = new( ) { Success = false, ExitCode = 5 }; + bool result = _evaluator.Evaluate( "$exitCode > 3", job ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void Evaluate_ExitCodeGreaterThan_WhenEqual_ReturnsFalse( ) { + WerkrJob job = new( ) { Success = false, ExitCode = 3 }; + bool result = _evaluator.Evaluate( "$exitCode > 3", job ); + Assert.IsFalse( result ); + } + + [TestMethod] + public void Evaluate_ExitCodeLessThan_WhenLess_ReturnsTrue( ) { + WerkrJob job = new( ) { Success = true, ExitCode = 0 }; + bool result = _evaluator.Evaluate( "$exitCode < 1", job ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void Evaluate_ExitCodeGreaterOrEqual_WhenEqual_ReturnsTrue( ) { + WerkrJob job = new( ) { Success = true, ExitCode = 5 }; + bool result = _evaluator.Evaluate( "$exitCode >= 5", job ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void Evaluate_ExitCodeLessOrEqual_WhenLess_ReturnsTrue( ) { + WerkrJob job = new( ) { Success = true, ExitCode = 2 }; + bool result = _evaluator.Evaluate( "$exitCode <= 5", job ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void Evaluate_ExitCodeNegativeValue_Match( ) { + WerkrJob job = new( ) { Success = false, ExitCode = -1 }; + bool result = _evaluator.Evaluate( "$exitCode == -1", job ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void Evaluate_NullExitCode_TreatedAsZero( ) { + WerkrJob job = new( ) { Success = true, ExitCode = null }; + bool result = _evaluator.Evaluate( "$exitCode == 0", job ); + Assert.IsTrue( result ); + } + + // ── Evaluate: Unknown expressions ── + + [TestMethod] + public void Evaluate_UnknownExpression_ReturnsFalse( ) { + WerkrJob job = new( ) { Success = true, ExitCode = 0 }; + bool result = _evaluator.Evaluate( "some unknown thing", job ); + Assert.IsFalse( result ); + } + + // ── EvaluateMultiple ── + + [TestMethod] + public void EvaluateMultiple_NullExpression_ReturnsTrue( ) { + List jobs = [new( ) { Success = false, ExitCode = 1 }]; + bool result = _evaluator.EvaluateMultiple( null, jobs, DependencyMode.All ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void EvaluateMultiple_EmptyPredecessors_EvaluatesAgainstDefault( ) { + // Default: Success=true, ExitCode=0 + bool result = _evaluator.EvaluateMultiple( "$? -eq $true", [], DependencyMode.All ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void EvaluateMultiple_AllMode_AllMustMatch( ) { + List jobs = [ + new( ) { Success = true, ExitCode = 0 }, + new( ) { Success = true, ExitCode = 0 }, + ]; + bool result = _evaluator.EvaluateMultiple( "$? -eq $true", jobs, DependencyMode.All ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void EvaluateMultiple_AllMode_OneFails_ReturnsFalse( ) { + List jobs = [ + new( ) { Success = true, ExitCode = 0 }, + new( ) { Success = false, ExitCode = 1 }, + ]; + bool result = _evaluator.EvaluateMultiple( "$? -eq $true", jobs, DependencyMode.All ); + Assert.IsFalse( result ); + } + + [TestMethod] + public void EvaluateMultiple_AnyMode_OnePasses_ReturnsTrue( ) { + List jobs = [ + new( ) { Success = true, ExitCode = 0 }, + new( ) { Success = false, ExitCode = 1 }, + ]; + bool result = _evaluator.EvaluateMultiple( "$? -eq $true", jobs, DependencyMode.Any ); + Assert.IsTrue( result ); + } + + [TestMethod] + public void EvaluateMultiple_AnyMode_NonePass_ReturnsFalse( ) { + List jobs = [ + new( ) { Success = false, ExitCode = 1 }, + new( ) { Success = false, ExitCode = 2 }, + ]; + bool result = _evaluator.EvaluateMultiple( "$? -eq $true", jobs, DependencyMode.Any ); + Assert.IsFalse( result ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowExecutorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowExecutorTests.cs new file mode 100644 index 0000000..1397441 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowExecutorTests.cs @@ -0,0 +1,843 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +using Werkr.Common.Configuration; +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Tasks; +using Werkr.Core.Workflows; +using Werkr.Data; +using Werkr.Data.Entities.Registration; +using Werkr.Data.Entities.Tasks; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Tests.Data.Unit.Workflows; + +[TestClass] +public class WorkflowExecutorTests { + private SqliteConnection _connection = null!; + private SqliteWerkrDbContext _dbContext = null!; + private WorkflowService _workflowService = null!; + private WorkflowExecutor _executor = null!; + private ConfigurableCommandDispatcher _dispatcher = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + + _workflowService = new WorkflowService( _dbContext, NullLogger.Instance ); + + ServiceCollection services = new( ); + _ = services.AddDbContext( + b => b.UseSqlite( _connection ), + ServiceLifetime.Scoped ); + _ = services.AddDbContext( + b => b.UseSqlite( _connection ), + ServiceLifetime.Scoped ); + ServiceProvider sp = services.BuildServiceProvider( ); + + AgentConnectionManager connectionManager = new( + sp.GetRequiredService( ), + NullLogger.Instance ); + + AgentResolver agentResolver = new( _dbContext, connectionManager, NullLogger.Instance ); + ConditionEvaluator conditionEvaluator = new( NullLogger.Instance ); + + IOptions outputOptions = Options.Create( new JobOutputOptions { + OutputDirectory = Path.Combine( Path.GetTempPath( ), $"werkr_test_{Guid.NewGuid( ):N}" ), + TailPreviewLength = 500, + } ); + JobOutputWriter outputWriter = new( outputOptions, NullLogger.Instance ); + SuccessCriteriaEvaluator criteriaEvaluator = new( NullLogger.Instance ); + + _dispatcher = new ConfigurableCommandDispatcher( ); + JobExecutionService jobExecutionService = new( + _dbContext, _dispatcher, agentResolver, outputWriter, + criteriaEvaluator, NullLogger.Instance ); + + _executor = new WorkflowExecutor( + _dbContext, _workflowService, jobExecutionService, + agentResolver, conditionEvaluator, + new WorkflowRunTracker( ), + NullLogger.Instance ); + } + + [TestCleanup] + public void TestCleanup( ) { + _dbContext?.Dispose( ); + _connection?.Dispose( ); + } + + // ── Basic Execution ── + + [TestMethod] + public async Task ExecuteAsync_SingleStep_CompletesSuccessfully( ) { + CancellationToken ct = TestContext.CancellationToken; + (Workflow workflow, _) = await SeedSingleStepWorkflowAsync( ct ); + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + Assert.IsNotNull( run.EndTime ); + } + + [TestMethod] + public async Task ExecuteAsync_LinearDag_ExecutesInOrder( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = await SeedLinearWorkflowAsync( ct ); + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + } + + [TestMethod] + public async Task ExecuteAsync_ParallelSteps_AllComplete( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = await SeedDiamondWorkflowAsync( ct ); + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + } + + // ── Control Flow ── + + [TestMethod] + public async Task ExecuteAsync_IfConditionTrue_ExecutesBranch( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = await SeedIfWorkflowAsync( "$? -eq $true", ct ); + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + } + + [TestMethod] + public async Task ExecuteAsync_IfConditionFalse_SkipsBranch( ) { + CancellationToken ct = TestContext.CancellationToken; + // The stub dispatcher returns exit code 0 / success=true, + // so "$? -eq $false" will cause the If branch to be skipped + Workflow workflow = await SeedIfWorkflowAsync( "$? -eq $false", ct ); + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + } + + // ── Workflow Runs ── + + [TestMethod] + public async Task GetRunsAsync_ReturnsRunHistory( ) { + CancellationToken ct = TestContext.CancellationToken; + (Workflow workflow, _) = await SeedSingleStepWorkflowAsync( ct ); + + _ = await _executor.ExecuteAsync( workflow, ct ); + _ = await _executor.ExecuteAsync( workflow, ct ); + + IReadOnlyList runs = await _executor.GetRunsAsync( workflow.Id, 50, ct ); + + Assert.HasCount( 2, runs ); + } + + [TestMethod] + public async Task GetRunAsync_ReturnsRunWithJobs( ) { + CancellationToken ct = TestContext.CancellationToken; + (Workflow workflow, _) = await SeedSingleStepWorkflowAsync( ct ); + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + WorkflowRun? loaded = await _executor.GetRunAsync( run.Id, ct ); + + Assert.IsNotNull( loaded ); + Assert.AreEqual( run.Id, loaded.Id ); + } + + [TestMethod] + public async Task GetRunAsync_NotFound_ReturnsNull( ) { + CancellationToken ct = TestContext.CancellationToken; + WorkflowRun? result = await _executor.GetRunAsync( Guid.NewGuid( ), ct ); + Assert.IsNull( result ); + } + + // ── Cancellation ── + + [TestMethod] + public async Task ExecuteAsync_Cancelled_SetsCancelledStatus( ) { + CancellationToken ct = TestContext.CancellationToken; + (Workflow workflow, _) = await SeedSingleStepWorkflowAsync( ct ); + + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource( ct ); + await cts.CancelAsync( ); + + WorkflowRun run = await _executor.ExecuteAsync( workflow, cts.Token ); + + Assert.AreEqual( WorkflowRunStatus.Cancelled, run.Status ); + } + + // ── No Agent Available ── + + [TestMethod] + public async Task ExecuteAsync_NoAgentAvailable_FailsRun( ) { + CancellationToken ct = TestContext.CancellationToken; + // Create workflow with step that has no matching agent (no agents registered) + Workflow workflow = new( ) { Name = "NoAgent", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + WerkrTask task = new( ) { + Name = "NoAgentTask", + Description = "Test", + ActionType = TaskActionType.ShellCommand, + Content = "echo hello", + TargetTags = ["nonexistent-agent-tag"], + }; + _ = _dbContext.Tasks.Add( task ); + _ = await _dbContext.SaveChangesAsync( ct ); + + _ = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + + // Reload to get steps + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Failed, run.Status ); + } + + // ── ElseIf Chain ── + + [TestMethod] + public async Task ExecuteAsync_ElseIfChain_FirstTrueBranchTakenRestSkipped( ) { + CancellationToken ct = TestContext.CancellationToken; + _ = await SeedAgentAsync( ct ); + WerkrTask task = await SeedTaskAsync( ct ); + + Workflow workflow = new( ) { Name = "ElseIfChain", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + // Root → If ($? -eq $true) → ElseIf ($? -eq $true) + // If branch taken because root succeeds; ElseIf skipped because prior branch was taken. + WorkflowStep root = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + + WorkflowStep ifStep = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 1, + ControlStatement = ControlStatement.If, + ConditionExpression = "$? -eq $true", + }, ct ); + await _workflowService.AddStepDependencyAsync( ifStep.Id, root.Id, ct ); + + WorkflowStep elseIfStep = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 2, + ControlStatement = ControlStatement.ElseIf, + ConditionExpression = "$? -eq $true", + }, ct ); + await _workflowService.AddStepDependencyAsync( elseIfStep.Id, ifStep.Id, ct ); + + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + + // Verify: root + If branch executed (2 jobs), ElseIf skipped + WorkflowRun? loaded = await _executor.GetRunAsync( run.Id, ct ); + Assert.IsNotNull( loaded ); + Assert.HasCount( 2, loaded.Jobs ); + } + + // ── While Loop ── + + [TestMethod] + public async Task ExecuteAsync_WhileLoop_RespectsMaxIterations( ) { + CancellationToken ct = TestContext.CancellationToken; + _ = await SeedAgentAsync( ct ); + WerkrTask task = await SeedTaskAsync( ct ); + + Workflow workflow = new( ) { Name = "WhileLoop", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + WorkflowStep root = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + + // While condition is always true (exit code == 0), MaxIterations guards against infinite loop + WorkflowStep whileStep = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 1, + ControlStatement = ControlStatement.While, + ConditionExpression = "$exitCode == 0", + MaxIterations = 3, + }, ct ); + await _workflowService.AddStepDependencyAsync( whileStep.Id, root.Id, ct ); + + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + + // root + 3 while iterations = 4 jobs + WorkflowRun? loaded = await _executor.GetRunAsync( run.Id, ct ); + Assert.IsNotNull( loaded ); + Assert.HasCount( 4, loaded.Jobs ); + } + + // ── Do Loop ── + + [TestMethod] + public async Task ExecuteAsync_DoLoop_ExecutesAtLeastOnce( ) { + CancellationToken ct = TestContext.CancellationToken; + _ = await SeedAgentAsync( ct ); + WerkrTask task = await SeedTaskAsync( ct ); + + Workflow workflow = new( ) { Name = "DoLoop", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + WorkflowStep root = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + + // Do: executes first, then checks condition. Condition false → stops after 1 iteration. + WorkflowStep doStep = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 1, + ControlStatement = ControlStatement.Do, + ConditionExpression = "$? -eq $false", + MaxIterations = 10, + }, ct ); + await _workflowService.AddStepDependencyAsync( doStep.Id, root.Id, ct ); + + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + + // root + 1 do iteration = 2 jobs (condition false after first execution) + WorkflowRun? loaded = await _executor.GetRunAsync( run.Id, ct ); + Assert.IsNotNull( loaded ); + Assert.HasCount( 2, loaded.Jobs ); + } + + // ── Dependency Failure ── + + [TestMethod] + public async Task ExecuteAsync_DependencyNotSatisfied_FailsWorkflow( ) { + CancellationToken ct = TestContext.CancellationToken; + _ = await SeedAgentAsync( ct ); + WerkrTask task = await SeedTaskAsync( ct ); + + Workflow workflow = new( ) { Name = "DepFail", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + // Root → If ($? -eq $false, skipped) → Child (Sequential, depends on If) + // If is skipped so Child's dependency is not satisfied → workflow fails. + WorkflowStep root = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + + WorkflowStep ifStep = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 1, + ControlStatement = ControlStatement.If, + ConditionExpression = "$? -eq $false", + }, ct ); + await _workflowService.AddStepDependencyAsync( ifStep.Id, root.Id, ct ); + + WorkflowStep child = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 2 }, ct ); + await _workflowService.AddStepDependencyAsync( child.Id, ifStep.Id, ct ); + + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Failed, run.Status ); + } + + // ── Empty Workflow ── + + [TestMethod] + public async Task ExecuteAsync_NoSteps_CompletesImmediately( ) { + CancellationToken ct = TestContext.CancellationToken; + + Workflow workflow = new( ) { Name = "Empty", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + Assert.IsNotNull( run.EndTime ); + } + + // ── Multi-Agent Resolution ── + + [TestMethod] + public async Task ExecuteAsync_MultiAgent_StepsResolveToCorrectAgents( ) { + CancellationToken ct = TestContext.CancellationToken; + + RegisteredConnection agentA = await SeedAgentAsync( "db-agent", ["db-server"], ct ); + RegisteredConnection agentB = await SeedAgentAsync( "app-agent", ["app-server"], ct ); + + WerkrTask dbTask = await SeedTaskAsync( "DbBackup", ["db-server"], null, ct ); + WerkrTask appTask = await SeedTaskAsync( "AppDeploy", ["app-server"], null, ct ); + + Workflow workflow = new( ) { Name = "MultiAgent", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + WorkflowStep s1 = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = dbTask.Id, Order = 0 }, ct ); + WorkflowStep s2 = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = appTask.Id, Order = 1 }, ct ); + await _workflowService.AddStepDependencyAsync( s2.Id, s1.Id, ct ); + + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + Assert.HasCount( 2, _dispatcher.InvokedAgentIds ); + Assert.AreEqual( agentA.Id, _dispatcher.InvokedAgentIds[0] ); + Assert.AreEqual( agentB.Id, _dispatcher.InvokedAgentIds[1] ); + } + + [TestMethod] + public async Task ExecuteAsync_MultiAgent_UnresolvableTags_FailsWorkflow( ) { + CancellationToken ct = TestContext.CancellationToken; + + _ = await SeedAgentAsync( "db-agent", ["db-server"], ct ); + WerkrTask dbTask = await SeedTaskAsync( "DbBackup", ["db-server"], null, ct ); + WerkrTask appTask = await SeedTaskAsync( "AppDeploy", ["nonexistent-tag"], null, ct ); + + Workflow workflow = new( ) { Name = "UnresolvableTags", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + WorkflowStep s1 = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = dbTask.Id, Order = 0 }, ct ); + WorkflowStep s2 = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = appTask.Id, Order = 1 }, ct ); + await _workflowService.AddStepDependencyAsync( s2.Id, s1.Id, ct ); + + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Failed, run.Status ); + } + + // ── Agent Connection Override ── + + [TestMethod] + public async Task ExecuteAsync_AgentOverride_BypassesTagResolution( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Tag-matching agent (would be selected by normal tag resolution) + _ = await SeedAgentAsync( "tag-agent", ["test"], ct ); + // Override agent (different tags, wouldn't match task's TargetTags) + RegisteredConnection overrideAgent = await SeedAgentAsync( "override-agent", ["other"], ct ); + + WerkrTask task = await SeedTaskAsync( ct ); // TargetTags = ["test"] + + Workflow workflow = new( ) { Name = "Override", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + _ = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 0, + AgentConnectionIdOverride = overrideAgent.Id, + }, ct ); + + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + Assert.HasCount( 1, _dispatcher.InvokedAgentIds ); + Assert.AreEqual( overrideAgent.Id, _dispatcher.InvokedAgentIds[0] ); + } + + [TestMethod] + public async Task ExecuteAsync_MixedResolution_TagsAndOverride( ) { + CancellationToken ct = TestContext.CancellationToken; + + RegisteredConnection tagAgent = await SeedAgentAsync( "tag-agent", ["test"], ct ); + RegisteredConnection pinnedAgent = await SeedAgentAsync( "pinned-agent", ["pinned"], ct ); + + WerkrTask task = await SeedTaskAsync( ct ); // TargetTags = ["test"] + + Workflow workflow = new( ) { Name = "Mixed", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + // Step 1: tag resolution → tagAgent + WorkflowStep s1 = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + // Step 2: override → pinnedAgent + WorkflowStep s2 = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 1, + AgentConnectionIdOverride = pinnedAgent.Id, + }, ct ); + await _workflowService.AddStepDependencyAsync( s2.Id, s1.Id, ct ); + + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + Assert.HasCount( 2, _dispatcher.InvokedAgentIds ); + Assert.AreEqual( tagAgent.Id, _dispatcher.InvokedAgentIds[0] ); + Assert.AreEqual( pinnedAgent.Id, _dispatcher.InvokedAgentIds[1] ); + } + + // ── Fan-In (DependencyMode) ── + + [TestMethod] + public async Task ExecuteAsync_FanIn_DependencyModeAll_WaitsForAll( ) { + CancellationToken ct = TestContext.CancellationToken; + _ = await SeedAgentAsync( ct ); + WerkrTask task = await SeedTaskAsync( ct ); + + Workflow workflow = new( ) { Name = "FanInAll", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + // A and B are independent, C depends on both with DependencyMode.All + WorkflowStep a = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + WorkflowStep b = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 1 }, ct ); + WorkflowStep c = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 2, + DependencyMode = DependencyMode.All, + }, ct ); + await _workflowService.AddStepDependencyAsync( c.Id, a.Id, ct ); + await _workflowService.AddStepDependencyAsync( c.Id, b.Id, ct ); + + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + + // All 3 steps produced jobs + WorkflowRun? loaded = await _executor.GetRunAsync( run.Id, ct ); + Assert.IsNotNull( loaded ); + Assert.HasCount( 3, loaded.Jobs ); + } + + [TestMethod] + public async Task ExecuteAsync_FanIn_DependencyModeAny_ProceedsWithSingle( ) { + CancellationToken ct = TestContext.CancellationToken; + _ = await SeedAgentAsync( ct ); + WerkrTask task = await SeedTaskAsync( ct ); + + Workflow workflow = new( ) { Name = "FanInAny", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + // Root → A (If $? -eq $false → skipped), Root → B (Sequential → executes) + // C depends on both A and B with DependencyMode.Any → proceeds with B + WorkflowStep root = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + + WorkflowStep a = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 1, + ControlStatement = ControlStatement.If, + ConditionExpression = "$? -eq $false", + }, ct ); + await _workflowService.AddStepDependencyAsync( a.Id, root.Id, ct ); + + WorkflowStep b = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 2 }, ct ); + await _workflowService.AddStepDependencyAsync( b.Id, root.Id, ct ); + + WorkflowStep c = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 3, + DependencyMode = DependencyMode.Any, + }, ct ); + await _workflowService.AddStepDependencyAsync( c.Id, a.Id, ct ); + await _workflowService.AddStepDependencyAsync( c.Id, b.Id, ct ); + + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + + // root + B + C = 3 jobs (A skipped) + WorkflowRun? loaded = await _executor.GetRunAsync( run.Id, ct ); + Assert.IsNotNull( loaded ); + Assert.HasCount( 3, loaded.Jobs ); + } + + [TestMethod] + public async Task ExecuteAsync_FanIn_DependencyModeAll_SkippedPredecessor_FailsWorkflow( ) { + CancellationToken ct = TestContext.CancellationToken; + _ = await SeedAgentAsync( ct ); + WerkrTask task = await SeedTaskAsync( ct ); + + Workflow workflow = new( ) { Name = "FanInAllFail", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + // Same fan-in structure as Any test but C uses DependencyMode.All. + // A is skipped (no result), so C's All check fails → workflow fails. + WorkflowStep root = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + + WorkflowStep a = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 1, + ControlStatement = ControlStatement.If, + ConditionExpression = "$? -eq $false", + }, ct ); + await _workflowService.AddStepDependencyAsync( a.Id, root.Id, ct ); + + WorkflowStep b = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 2 }, ct ); + await _workflowService.AddStepDependencyAsync( b.Id, root.Id, ct ); + + WorkflowStep c = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 3, + DependencyMode = DependencyMode.All, + }, ct ); + await _workflowService.AddStepDependencyAsync( c.Id, a.Id, ct ); + await _workflowService.AddStepDependencyAsync( c.Id, b.Id, ct ); + + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Failed, run.Status ); + } + + // ── Per-Task SuccessCriteria ── + + [TestMethod] + public async Task ExecuteAsync_PerTaskSuccessCriteria_CustomCriteriaEvaluated( ) { + CancellationToken ct = TestContext.CancellationToken; + _ = await SeedAgentAsync( ct ); + + // Task with SuccessCriteria = "always" — succeeds even with non-zero exit code + _dispatcher.DefaultExitCode = 1; + WerkrTask task = await SeedTaskAsync( "AlwaysTask", ["test"], + successCriteria: "always", ct: ct ); + + Workflow workflow = new( ) { Name = "Criteria", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + _ = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + + WorkflowRun run = await _executor.ExecuteAsync( workflow, ct ); + + Assert.AreEqual( WorkflowRunStatus.Completed, run.Status ); + + // Verify the job succeeded despite exit code 1 + WorkflowRun? loaded = await _executor.GetRunAsync( run.Id, ct ); + Assert.IsNotNull( loaded ); + Assert.HasCount( 1, loaded.Jobs ); + Assert.IsTrue( loaded.Jobs.First( ).Success ); + } + + // ── Helper Methods ── + + private Task SeedAgentAsync( CancellationToken ct ) => + SeedAgentAsync( "test-agent", ["test"], ct ); + + private async Task SeedAgentAsync( + string name, string[] tags, CancellationToken ct ) { + RegisteredConnection agent = new( ) { + Id = Guid.NewGuid( ), + ConnectionName = name, + RemoteUrl = "https://localhost:5001", + Tags = tags, + Status = ConnectionStatus.Connected, + SharedKey = new byte[32], + IsServer = true, + }; + _ = _dbContext.RegisteredConnections.Add( agent ); + _ = await _dbContext.SaveChangesAsync( ct ); + return agent; + } + + private Task SeedTaskAsync( CancellationToken ct ) => + SeedTaskAsync( "TestTask", ["test"], null, ct ); + + private async Task SeedTaskAsync( + string name, string[] targetTags, + string? successCriteria, CancellationToken ct ) { + WerkrTask task = new( ) { + Name = name, + Description = "Test", + ActionType = TaskActionType.ShellCommand, + Content = "echo hello", + TargetTags = targetTags, + SuccessCriteria = successCriteria, + }; + _ = _dbContext.Tasks.Add( task ); + _ = await _dbContext.SaveChangesAsync( ct ); + return task; + } + + private async Task<(Workflow Workflow, WorkflowStep Step)> SeedSingleStepWorkflowAsync( + CancellationToken ct ) { + _ = await SeedAgentAsync( ct ); + WerkrTask task = await SeedTaskAsync( ct ); + + Workflow workflow = new( ) { Name = "Single", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + WorkflowStep step = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + + // Reload with steps + workflow = (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + return (workflow, step); + } + + private async Task SeedLinearWorkflowAsync( CancellationToken ct ) { + _ = await SeedAgentAsync( ct ); + WerkrTask task = await SeedTaskAsync( ct ); + + Workflow workflow = new( ) { Name = "Linear", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + WorkflowStep s1 = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + WorkflowStep s2 = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 1 }, ct ); + await _workflowService.AddStepDependencyAsync( s2.Id, s1.Id, ct ); + + return (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + } + + private async Task SeedDiamondWorkflowAsync( CancellationToken ct ) { + _ = await SeedAgentAsync( ct ); + WerkrTask task = await SeedTaskAsync( ct ); + + Workflow workflow = new( ) { Name = "Diamond", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + // A → B, A → C, B → D, C → D (diamond shape) + WorkflowStep a = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + WorkflowStep b = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 1 }, ct ); + WorkflowStep c = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 2 }, ct ); + WorkflowStep d = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 3 }, ct ); + await _workflowService.AddStepDependencyAsync( b.Id, a.Id, ct ); + await _workflowService.AddStepDependencyAsync( c.Id, a.Id, ct ); + await _workflowService.AddStepDependencyAsync( d.Id, b.Id, ct ); + await _workflowService.AddStepDependencyAsync( d.Id, c.Id, ct ); + + return (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + } + + private async Task SeedIfWorkflowAsync( string condition, CancellationToken ct ) { + _ = await SeedAgentAsync( ct ); + WerkrTask task = await SeedTaskAsync( ct ); + + Workflow workflow = new( ) { Name = "IfBranch", Description = "" }; + _ = await _workflowService.CreateAsync( workflow, ct ); + + // Step 1: Sequential (root) + WorkflowStep root = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = task.Id, Order = 0 }, ct ); + + // Step 2: If branch (depends on root) + WorkflowStep ifStep = await _workflowService.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = task.Id, + Order = 1, + ControlStatement = ControlStatement.If, + ConditionExpression = condition, + }, ct ); + await _workflowService.AddStepDependencyAsync( ifStep.Id, root.Id, ct ); + + return (await _workflowService.GetByIdAsync( workflow.Id, ct ))!; + } + + /// + /// Configurable command dispatcher for testing. Returns output with a configurable + /// exit code and tracks which agents received invocations. + /// + private sealed class ConfigurableCommandDispatcher : ICommandDispatcher { + /// Default exit code returned for all invocations unless overridden per-agent. + public int DefaultExitCode { get; set; } + + /// Per-agent exit code overrides. + public Dictionary AgentExitCodes { get; } = []; + + /// Tracks which agent IDs received invocations, in order. + public List InvokedAgentIds { get; } = []; + + public async IAsyncEnumerable ExecuteCommandAsync( + Guid agentConnectionId, + OperatorType operatorType, + string command, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default ) { + InvokedAgentIds.Add( agentConnectionId ); + int exitCode = AgentExitCodes.GetValueOrDefault( agentConnectionId, DefaultExitCode ); + await Task.CompletedTask; + yield return OperatorOutput.Create( "Information", "OK" ); + yield return OperatorOutput.Create( "Information", $"Process exited with code {exitCode}" ); + } + + public async IAsyncEnumerable ExecuteScriptAsync( + Guid agentConnectionId, + OperatorType operatorType, + string scriptPath, + IEnumerable? args, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default ) { + InvokedAgentIds.Add( agentConnectionId ); + int exitCode = AgentExitCodes.GetValueOrDefault( agentConnectionId, DefaultExitCode ); + await Task.CompletedTask; + yield return OperatorOutput.Create( "Information", "OK" ); + yield return OperatorOutput.Create( "Information", $"Process exited with code {exitCode}" ); + } + + public async IAsyncEnumerable ExecuteActionAsync( + Guid agentConnectionId, + ActionDescriptor descriptor, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default ) { + InvokedAgentIds.Add( agentConnectionId ); + int exitCode = AgentExitCodes.GetValueOrDefault( agentConnectionId, DefaultExitCode ); + await Task.CompletedTask; + yield return OperatorOutput.Create( "Information", "OK" ); + yield return OperatorOutput.Create( "Information", $"Process exited with code {exitCode}" ); + } + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowServiceTests.cs new file mode 100644 index 0000000..270a925 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowServiceTests.cs @@ -0,0 +1,374 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Core.Workflows; +using Werkr.Data; +using Werkr.Data.Entities.Tasks; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Tests.Data.Unit.Workflows; + +[TestClass] +public class WorkflowServiceTests { + private SqliteConnection _connection = null!; + private SqliteWerkrDbContext _dbContext = null!; + private WorkflowService _service = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + + _service = new WorkflowService( _dbContext, NullLogger.Instance ); + } + + [TestCleanup] + public void TestCleanup( ) { + _dbContext?.Dispose( ); + _connection?.Dispose( ); + } + + // ── CRUD ── + + [TestMethod] + public async Task CreateAsync_CreatesWorkflow( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "Test Workflow", Description = "Test" }; + + Workflow created = await _service.CreateAsync( workflow, ct ); + + Assert.IsGreaterThan( 0L, created.Id ); + Assert.AreEqual( "Test Workflow", created.Name ); + } + + [TestMethod] + public async Task CreateAsync_DuplicateName_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow w1 = new( ) { Name = "Dupe", Description = "First" }; + _ = await _service.CreateAsync( w1, ct ); + + Workflow w2 = new( ) { Name = "Dupe", Description = "Second" }; + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.CreateAsync( w2, ct ) ); + } + + [TestMethod] + public async Task GetByIdAsync_WithStepsAndDependencies_LoadsAll( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "Full Load", Description = "" }; + _ = await _service.CreateAsync( workflow, ct ); + await SeedTaskAsync( ct ); + + WorkflowStep step1 = await _service.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = 1, Order = 0 }, ct ); + WorkflowStep step2 = await _service.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = 1, Order = 1 }, ct ); + await _service.AddStepDependencyAsync( step2.Id, step1.Id, ct ); + + Workflow? loaded = await _service.GetByIdAsync( workflow.Id, ct ); + + Assert.IsNotNull( loaded ); + Assert.HasCount( 2, loaded.Steps ); + } + + [TestMethod] + public async Task GetByIdAsync_NotFound_ReturnsNull( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow? result = await _service.GetByIdAsync( 999, ct ); + Assert.IsNull( result ); + } + + [TestMethod] + public async Task UpdateAsync_UpdatesFields( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "Original", Description = "Desc" }; + _ = await _service.CreateAsync( workflow, ct ); + + Workflow update = new( ) { Id = workflow.Id, Name = "Updated", Description = "New", Enabled = false }; + Workflow updated = await _service.UpdateAsync( update, ct ); + + Assert.AreEqual( "Updated", updated.Name ); + Assert.IsFalse( updated.Enabled ); + } + + [TestMethod] + public async Task UpdateAsync_NotFound_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow update = new( ) { Id = 999, Name = "Nope" }; + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.UpdateAsync( update, ct ) ); + } + + [TestMethod] + public async Task DeleteAsync_RemovesWorkflow( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "ToDelete", Description = "" }; + _ = await _service.CreateAsync( workflow, ct ); + + await _service.DeleteAsync( workflow.Id, ct ); + + Workflow? gone = await _service.GetByIdAsync( workflow.Id, ct ); + Assert.IsNull( gone ); + } + + [TestMethod] + public async Task DeleteAsync_NotFound_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.DeleteAsync( 999, ct ) ); + } + + [TestMethod] + public async Task GetAllAsync_ReturnsAllWorkflows( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow w1 = new( ) { Name = "Alpha", Description = "" }; + Workflow w2 = new( ) { Name = "Beta", Description = "" }; + _ = await _service.CreateAsync( w1, ct ); + _ = await _service.CreateAsync( w2, ct ); + + IReadOnlyList all = await _service.GetAllAsync( ct ); + + Assert.HasCount( 2, all ); + } + + // ── Step Management ── + + [TestMethod] + public async Task AddStepAsync_AddsStepToWorkflow( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "StepTest", Description = "" }; + _ = await _service.CreateAsync( workflow, ct ); + await SeedTaskAsync( ct ); + + WorkflowStep step = new( ) { TaskId = 1, Order = 0 }; + WorkflowStep created = await _service.AddStepAsync( workflow.Id, step, ct ); + + Assert.IsGreaterThan( 0L, created.Id ); + Assert.AreEqual( workflow.Id, created.WorkflowId ); + } + + [TestMethod] + public async Task AddStepAsync_WorkflowNotFound_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + WorkflowStep step = new( ) { TaskId = 1, Order = 0 }; + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.AddStepAsync( 999, step, ct ) ); + } + + [TestMethod] + public async Task RemoveStepAsync_RemovesStep( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "RemoveStep", Description = "" }; + _ = await _service.CreateAsync( workflow, ct ); + await SeedTaskAsync( ct ); + + WorkflowStep step = await _service.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = 1, Order = 0 }, ct ); + + await _service.RemoveStepAsync( step.Id, ct ); + + Workflow? loaded = await _service.GetByIdAsync( workflow.Id, ct ); + Assert.IsEmpty( loaded!.Steps ); + } + + [TestMethod] + public async Task UpdateStepAsync_UpdatesFields( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "UpdateStep", Description = "" }; + _ = await _service.CreateAsync( workflow, ct ); + await SeedTaskAsync( ct ); + + WorkflowStep step = await _service.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = 1, Order = 0 }, ct ); + + WorkflowStep update = new( ) { + Id = step.Id, + TaskId = 1, + Order = 5, + ControlStatement = ControlStatement.If, + ConditionExpression = "$? -eq $true", + MaxIterations = 50, + }; + WorkflowStep updated = await _service.UpdateStepAsync( update, ct ); + + Assert.AreEqual( 5, updated.Order ); + Assert.AreEqual( ControlStatement.If, updated.ControlStatement ); + Assert.AreEqual( "$? -eq $true", updated.ConditionExpression ); + Assert.AreEqual( 50, updated.MaxIterations ); + } + + // ── Dependency Management ── + + [TestMethod] + public async Task AddStepDependencyAsync_CreatesDependency( ) { + CancellationToken ct = TestContext.CancellationToken; + (WorkflowStep s1, WorkflowStep s2) = await SeedTwoStepsAsync( ct ); + + await _service.AddStepDependencyAsync( s2.Id, s1.Id, ct ); + + WorkflowStepDependency? dep = await _dbContext.WorkflowStepDependencies + .FirstOrDefaultAsync( d => d.StepId == s2.Id && d.DependsOnStepId == s1.Id, ct ); + Assert.IsNotNull( dep ); + } + + [TestMethod] + public async Task AddStepDependencyAsync_SelfReference_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.AddStepDependencyAsync( 1, 1, ct ) ); + } + + [TestMethod] + public async Task AddStepDependencyAsync_Duplicate_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + (WorkflowStep s1, WorkflowStep s2) = await SeedTwoStepsAsync( ct ); + await _service.AddStepDependencyAsync( s2.Id, s1.Id, ct ); + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.AddStepDependencyAsync( s2.Id, s1.Id, ct ) ); + } + + [TestMethod] + public async Task RemoveStepDependencyAsync_RemovesDependency( ) { + CancellationToken ct = TestContext.CancellationToken; + (WorkflowStep s1, WorkflowStep s2) = await SeedTwoStepsAsync( ct ); + await _service.AddStepDependencyAsync( s2.Id, s1.Id, ct ); + + await _service.RemoveStepDependencyAsync( s2.Id, s1.Id, ct ); + + WorkflowStepDependency? dep = await _dbContext.WorkflowStepDependencies + .FirstOrDefaultAsync( d => d.StepId == s2.Id && d.DependsOnStepId == s1.Id, ct ); + Assert.IsNull( dep ); + } + + // ── DAG Validation ── + + [TestMethod] + public async Task ValidateDagAsync_LinearDag_ReturnsTopologicalOrder( ) { + CancellationToken ct = TestContext.CancellationToken; + (WorkflowStep s1, WorkflowStep s2) = await SeedTwoStepsAsync( ct ); + await _service.AddStepDependencyAsync( s2.Id, s1.Id, ct ); + + IReadOnlyList sorted = await _service.ValidateDagAsync( + s1.WorkflowId, ct ); + + Assert.HasCount( 2, sorted ); + Assert.AreEqual( s1.Id, sorted[0].Id ); + Assert.AreEqual( s2.Id, sorted[1].Id ); + } + + [TestMethod] + public async Task ValidateDagAsync_CycleDetected_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + (WorkflowStep s1, WorkflowStep s2) = await SeedTwoStepsAsync( ct ); + await _service.AddStepDependencyAsync( s2.Id, s1.Id, ct ); + await _service.AddStepDependencyAsync( s1.Id, s2.Id, ct ); + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.ValidateDagAsync( s1.WorkflowId, ct ) ); + } + + [TestMethod] + public async Task ValidateDagAsync_EmptyWorkflow_ReturnsEmpty( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "EmptyDag", Description = "" }; + _ = await _service.CreateAsync( workflow, ct ); + + IReadOnlyList sorted = await _service.ValidateDagAsync( workflow.Id, ct ); + + Assert.IsEmpty( sorted ); + } + + [TestMethod] + public async Task ValidateDagAsync_WorkflowNotFound_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.ValidateDagAsync( 999, ct ) ); + } + + [TestMethod] + public async Task GetTopologicalLevelsAsync_GroupsParallelSteps( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "Levels", Description = "" }; + _ = await _service.CreateAsync( workflow, ct ); + await SeedTaskAsync( ct ); + + // A → B, A → C (B and C are parallel at level 1) + WorkflowStep a = await _service.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = 1, Order = 0 }, ct ); + WorkflowStep b = await _service.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = 1, Order = 1 }, ct ); + WorkflowStep c = await _service.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = 1, Order = 2 }, ct ); + await _service.AddStepDependencyAsync( b.Id, a.Id, ct ); + await _service.AddStepDependencyAsync( c.Id, a.Id, ct ); + + IReadOnlyList> levels = + await _service.GetTopologicalLevelsAsync( workflow.Id, ct ); + + Assert.HasCount( 2, levels ); + Assert.HasCount( 1, levels[0] ); // Level 0: A + Assert.HasCount( 2, levels[1] ); // Level 1: B, C + } + + // ── Control Flow Validation ── + + [TestMethod] + public async Task ValidateDagAsync_IfWithoutCondition_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "BadIf", Description = "" }; + _ = await _service.CreateAsync( workflow, ct ); + await SeedTaskAsync( ct ); + + _ = await _service.AddStepAsync( workflow.Id, + new WorkflowStep { + TaskId = 1, + Order = 0, + ControlStatement = ControlStatement.If, + ConditionExpression = null, + }, ct ); + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _service.ValidateDagAsync( workflow.Id, ct ) ); + } + + // ── Helpers ── + + private async Task SeedTaskAsync( CancellationToken ct ) { + if (!await _dbContext.Tasks.AnyAsync( ct )) { + _ = _dbContext.Tasks.Add( new WerkrTask { + Name = "SeedTask", + Description = "Test task", + ActionType = TaskActionType.ShellCommand, + Content = "echo hello", + TargetTags = ["test"], + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + } + } + + private async Task<(WorkflowStep S1, WorkflowStep S2)> SeedTwoStepsAsync( CancellationToken ct ) { + Workflow workflow = new( ) { Name = $"W_{Guid.NewGuid( ):N}", Description = "" }; + _ = await _service.CreateAsync( workflow, ct ); + await SeedTaskAsync( ct ); + + WorkflowStep s1 = await _service.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = 1, Order = 0 }, ct ); + WorkflowStep s2 = await _service.AddStepAsync( workflow.Id, + new WorkflowStep { TaskId = 1, Order = 1 }, ct ); + + return (s1, s2); + } +} diff --git a/src/Test/Werkr.Tests.Data/Werkr.Tests.Data.csproj b/src/Test/Werkr.Tests.Data/Werkr.Tests.Data.csproj new file mode 100644 index 0000000..eb7ce68 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Werkr.Tests.Data.csproj @@ -0,0 +1,21 @@ + + + + Exe + false + true + true + false + + + + + + + + + + + + + diff --git a/src/Test/Werkr.Tests.Data/packages.lock.json b/src/Test/Werkr.Tests.Data/packages.lock.json new file mode 100644 index 0000000..e4e968c --- /dev/null +++ b/src/Test/Werkr.Tests.Data/packages.lock.json @@ -0,0 +1,1015 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "MSTest": { + "type": "Direct", + "requested": "[4.1.0, )", + "resolved": "4.1.0", + "contentHash": "2bk47yg7HcHRyf6Zf0XgCZicTVTQj4D5lonYTO7lWMxCQB+x66VrQNc2dADBfzthKXfHaA37m8i+VV5h6SbWiA==", + "dependencies": { + "MSTest.TestAdapter": "4.1.0", + "MSTest.TestFramework": "4.1.0", + "Microsoft.NET.Test.Sdk": "18.0.1", + "Microsoft.Testing.Extensions.CodeCoverage": "18.4.1", + "Microsoft.Testing.Extensions.TrxReport": "2.1.0" + } + }, + "Grpc.AspNetCore.Server": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0" + } + }, + "Grpc.AspNetCore.Server.ClientFactory": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==", + "dependencies": { + "Grpc.AspNetCore.Server": "2.76.0", + "Grpc.Net.ClientFactory": "2.76.0" + } + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==", + "dependencies": { + "Grpc.Core.Api": "2.76.0" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA==" + }, + "Microsoft.DiaSymReader": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "QcZrCETsBJqy/vQpFtJc+jSXQ0K5sucQ6NUFbTNVHD4vfZZOwjZ/3sBzczkC4DityhD3AVO/+K/+9ioLs1AgRA==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Features": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "djFt1Jt+2uREWWVQiiA4ilYBDtHHY7nK08c5K8xBD9+XFNw3KDVprylrMkH08bZGK3ZHRAkS7JDV9srfLrcm/g==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "M5gWob3dtzlF14oto1lR1ZuSJrR0gGc+obv7zY9LGmX5y3Ndpve29MrrjqJW/m4CFud4TE/KFUuHjjtwxhCO8g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.Telemetry": "10.3.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PBlaoYeusaxNYyN4WFjzcXWlUDSvLUPxy/e6oP1SONOOYA/oBWT2uBmFGJMV9VTtXiXXxCB39LqlYWbsWE4UKA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "dQKlVXzqflsv5X8iDlAN5YmTL1GcLCrOLKo1s9PNdfjqxeu0S/jmWTfiLGno+8+o1qFL3+VFAH5/ftmypN+sPw==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.ServiceDiscovery.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Features": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.0.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "8.0.1" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==", + "dependencies": { + "Microsoft.CodeCoverage": "18.0.1", + "Microsoft.TestPlatform.TestHost": "18.0.1" + } + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Microsoft.Testing.Extensions.CodeCoverage": { + "type": "Transitive", + "resolved": "18.4.1", + "contentHash": "l1VZM9dg9s76L5D288ipAT4HRYDJ6Vxh8wX20gfS9VnpueedRfN4/aGNn4oA1g6pwq2WSM3Ci7IoSSGPiqu+WQ==", + "dependencies": { + "Microsoft.DiaSymReader": "2.0.0", + "Microsoft.Extensions.DependencyModel": "8.0.2", + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "5TwgTx2u7k9Al/xbZ18QXq4Hdy2xewkVTI6K3sk+jY2ykqUkIKNuj7rFu3GOV5KnEUkevhw6eZcyZs77STHJIA==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "cXmP225WcMLLOSrW8xekaNhfzdBwXX3cbXbE5qSzmLbK0KZe3z8rAObKj70FWiPPPzm2W22x0ZW93gsmAfK6Mg==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.1.0", + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "D8xmIJYQFJ6D49Rx5/vPrkZZxb338Jkew+eSqZLBfBiWKw4QZKy3i1BOXiLfz0lOmaNErwDz/YWRojCdNl+B9Q==", + "dependencies": { + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Extensions.VSTestBridge": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "bNRIEA2YoGr+Y+7LHdA7i1U80+7BAdf4K4Qh4Kx6eKkoBK/NV7QpoMg+GWPP0/eqAFzuUmUOIPVZ87Oo0Vyxmw==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Microsoft.Testing.Extensions.Telemetry": "2.1.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.1.0", + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "aHkjNTGIA+Zbdw6RJgSFrbDrCjO0CgqpElqYcvkRSeUhBv2bKarnvU3ep786U7UqrPlArT/B7VmImRibJD0Zrg==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "UpfPebXQtHGrWz21+YLHmJSm+5zsuPE9U9pfdCtoB+67g75fDmWlNgpkH2ZmdVhSwkjNIed9Icg8Iu63z2ei5Q==", + "dependencies": { + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "MSTest.Analyzers": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "4ElL/aqomiUInr090VN4udqz46AuszXLrifHkLrgj0zb7na8eAoyUQt3BwDLTcGd1bSkmk3SfD02rZtKU+ZiqQ==" + }, + "MSTest.TestAdapter": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "bRW1Hftwq0XbcVExcAbj4YAfSZDRAziL0mygDkPBvaUe2nSsWFQIatze5lHVjPFJMvSFgWnItku4pguIy5FowQ==", + "dependencies": { + "MSTest.TestFramework": "4.1.0", + "Microsoft.Testing.Extensions.VSTestBridge": "2.1.0", + "Microsoft.Testing.Platform.MSBuild": "2.1.0" + } + }, + "MSTest.TestFramework": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "BzpvsK+CRbk6khwY62h+7HfYzIxtJXyPv9tOI9T90cy5CVy+WI1JkN4ZaNL4Dobqb6dywSwabLTIbPZKpdrr+A==", + "dependencies": { + "MSTest.Analyzers": "4.1.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "OpenTelemetry.Api": "1.15.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "werkr.api": { + "type": "Project", + "dependencies": { + "Grpc.AspNetCore": "[2.76.0, )", + "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.3, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.3, )", + "Microsoft.IdentityModel.JsonWebTokens": "[8.16.0, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.File": "[7.0.0, )", + "Serilog.Sinks.OpenTelemetry": "[4.2.0, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Core": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )", + "Werkr.ServiceDefaults": "[1.0.0, )" + } + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.AspNetCore.Authorization": "[10.0.3, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.core": { + "type": "Project", + "dependencies": { + "Grpc.Net.Client": "[2.76.0, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.3, )", + "System.Security.Cryptography.ProtectedData": "[10.0.3, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )" + } + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "werkr.servicedefaults": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Grpc.AspNetCore": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==", + "dependencies": { + "Google.Protobuf": "3.31.1", + "Grpc.AspNetCore.Server.ClientFactory": "2.76.0", + "Grpc.Tools": "2.76.0" + } + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Grpc.Net.ClientFactory": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==", + "dependencies": { + "Grpc.Net.Client": "2.76.0", + "Microsoft.Extensions.Http": "8.0.0" + } + }, + "Microsoft.AspNetCore.Authentication.JwtBearer": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "TBDs8e9y2vJHp14EwNfnIZUNrm6siw8PAAU5laOrYFuGgRxx8oCdxZyfTgp1Oy/icUk9h/XtpYBHPnXIG0f2/g==", + "dependencies": { + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1" + } + }, + "Microsoft.AspNetCore.Authorization": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "SAvSrKDgnY5GDjDAngOXxPhUvEKlTU/0zIq8zidqHvh/xnZBPs0Vc4LqwyvnmnafNnyUaivtRABz4K4wodXfSg==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.3.0", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Resilience": "10.3.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.ServiceDiscovery": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "OpenTelemetry": "1.15.0" + } + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Sinks.OpenTelemetry": { + "type": "CentralTransitive", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", + "dependencies": { + "Google.Protobuf": "3.30.1", + "Grpc.Net.Client": "2.70.0", + "Serilog": "4.2.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.0.1", + "contentHash": "GJw3bYkWpOgvN3tJo5X4lYUeIFA2HD293FPUhKmp7qxS+g5ywAb34Dnd3cDAFLkcMohy5XTpoaZ4uAHuw0uSPQ==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.0.1", + "Microsoft.IdentityModel.Tokens": "8.0.1" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" + } + } + } +} \ No newline at end of file diff --git a/src/Test/Werkr.Tests.Server/ActionParameterRegistryTests.cs b/src/Test/Werkr.Tests.Server/ActionParameterRegistryTests.cs new file mode 100644 index 0000000..b5c8167 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/ActionParameterRegistryTests.cs @@ -0,0 +1,116 @@ +using Werkr.Server.Services; + +namespace Werkr.Tests.Server; + +[TestClass] +public class ActionParameterRegistryTests { + [TestMethod] + public void All_Contains_Eleven_Actions( ) { + Assert.HasCount( 11, ActionParameterRegistry.All ); + } + + [TestMethod] + public void Actions_Dictionary_Is_Case_Insensitive( ) { + Assert.IsTrue( ActionParameterRegistry.Actions.ContainsKey( "copyfile" ) ); + Assert.IsTrue( ActionParameterRegistry.Actions.ContainsKey( "COPYFILE" ) ); + Assert.IsTrue( ActionParameterRegistry.Actions.ContainsKey( "CopyFile" ) ); + } + + [TestMethod] + [DataRow( "CopyFile" )] + [DataRow( "MoveFile" )] + [DataRow( "RenameFile" )] + [DataRow( "DeleteFile" )] + [DataRow( "CreateFile" )] + [DataRow( "CreateDirectory" )] + [DataRow( "TestExists" )] + [DataRow( "ClearContent" )] + [DataRow( "WriteContent" )] + [DataRow( "StartProcess" )] + [DataRow( "StopProcess" )] + public void Actions_Contains_Expected_Key( string key ) { + Assert.IsTrue( + ActionParameterRegistry.Actions.ContainsKey( key ), + $"Missing action key: {key}" ); + } + + [TestMethod] + public void Every_Descriptor_Has_At_Least_One_Field( ) { + foreach (ActionFormDescriptor desc in ActionParameterRegistry.All) { + Assert.IsNotEmpty( + desc.Fields, + $"Action '{desc.Key}' has no fields." ); + } + } + + [TestMethod] + public void Every_Descriptor_Has_NonEmpty_DisplayName_And_Description( ) { + foreach (ActionFormDescriptor desc in ActionParameterRegistry.All) { + Assert.IsFalse( + string.IsNullOrWhiteSpace( desc.DisplayName ), + $"Action '{desc.Key}' has empty DisplayName." ); + Assert.IsFalse( + string.IsNullOrWhiteSpace( desc.Description ), + $"Action '{desc.Key}' has empty Description." ); + } + } + + [TestMethod] + public void Select_Fields_Have_Options( ) { + foreach (ActionFormDescriptor desc in ActionParameterRegistry.All) { + foreach (FieldDescriptor field in desc.Fields) { + if (field.Type == FieldType.Select) { + Assert.IsNotNull( + field.Options, + $"'{desc.Key}.{field.Name}' is Select but has no Options." ); + Assert.IsNotEmpty( + field.Options, + $"'{desc.Key}.{field.Name}' is Select but Options is empty." ); + } + } + } + } + + [TestMethod] + public void Encodings_Contains_Utf8( ) { + CollectionAssert.Contains( ActionParameterRegistry.Encodings, "utf-8" ); + } + + [TestMethod] + public void Encodings_Has_Nine_Values( ) { + Assert.HasCount( 9, ActionParameterRegistry.Encodings ); + } + + [TestMethod] + public void CopyFile_Has_Expected_Fields( ) { + ActionFormDescriptor desc = ActionParameterRegistry.Actions["CopyFile"]; + Assert.HasCount( 4, desc.Fields ); + Assert.AreEqual( "Source", desc.Fields[0].Name ); + Assert.AreEqual( "Destination", desc.Fields[1].Name ); + Assert.AreEqual( "Overwrite", desc.Fields[2].Name ); + Assert.AreEqual( "Recursive", desc.Fields[3].Name ); + } + + [TestMethod] + public void TestExists_Type_Is_Select_With_Three_Options( ) { + ActionFormDescriptor desc = ActionParameterRegistry.Actions["TestExists"]; + FieldDescriptor typeField = desc.Fields[1]; + Assert.AreEqual( "Type", typeField.Name ); + Assert.AreEqual( FieldType.Select, typeField.Type ); + Assert.HasCount( 3, typeField.Options! ); + CollectionAssert.Contains( typeField.Options, "Any" ); + CollectionAssert.Contains( typeField.Options, "File" ); + CollectionAssert.Contains( typeField.Options, "Directory" ); + } + + [TestMethod] + public void CreateFile_Encoding_Default_Is_Utf8( ) { + ActionFormDescriptor desc = ActionParameterRegistry.Actions["CreateFile"]; + FieldDescriptor? enc = null; + foreach (FieldDescriptor f in desc.Fields) { + if (f.Name == "Encoding") { enc = f; break; } + } + Assert.IsNotNull( enc ); + Assert.AreEqual( "utf-8", enc.DefaultValue ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Authorization/AuthorizationAttributeTests.cs b/src/Test/Werkr.Tests.Server/Authorization/AuthorizationAttributeTests.cs new file mode 100644 index 0000000..b8dbfb5 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Authorization/AuthorizationAttributeTests.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Werkr.Tests.Server.Authorization; + +/// +/// Verifies that Blazor pages have the correct configuration. +/// These are reflection-based tests that validate server-side authorization gates independent of NavMenu visibility. +/// +[TestClass] +public class AuthorizationAttributeTests { +} diff --git a/src/Test/Werkr.Tests.Server/Authorization/PageAuthorizationTests.cs b/src/Test/Werkr.Tests.Server/Authorization/PageAuthorizationTests.cs new file mode 100644 index 0000000..cdba3be --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Authorization/PageAuthorizationTests.cs @@ -0,0 +1,141 @@ +using System.Reflection; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; + +namespace Werkr.Tests.Server.Authorization; + +/// +/// Reflection-based tests verifying every Blazor page has the correct +/// configuration (§3.12.7). +/// +[TestClass] +public class PageAuthorizationTests { + /// + /// Mapping of page routes to expected authorization behaviour. + /// True = requires Admin role, False = any authenticated, null = anonymous. + /// + private static readonly Dictionary s_expectedAuthorization = new( StringComparer.OrdinalIgnoreCase ) { + // Admin-only pages + ["/settings"] = "Admin", + ["/agents/{AgentId:guid}"] = "Admin", + ["/agents"] = "Admin", + ["/admin/users"] = "Admin", + ["/admin/users/create"] = "Admin", + ["/admin/users/{UserId}"] = "Admin", + ["/agents/register"] = "Admin", + + // Admin + Operator pages + ["/operators"] = "Admin,Operator", + ["/operators/{AgentId:guid}"] = "Admin,Operator", + + // Any authenticated + ["/"] = "", + ["/account/change-password"] = "", + ["/account/manage"] = "", + ["/account/manage/mfa"] = "", + ["/calendar"] = "", + ["/tasklist"] = "", + ["/jobs"] = "", + ["/jobs/{Id:guid}"] = "", + ["/schedules"] = "", + ["/schedules/create"] = "", + ["/schedules/{Id:guid}"] = "", + ["/tasks"] = "", + ["/tasks/create"] = "", + ["/tasks/{Id:long}"] = "", + ["/workflows"] = "", + ["/workflows/create"] = "", + ["/workflows/{Id:long}"] = "", + ["/workflows/{WorkflowId:long}/runs"] = "", + ["/workflows/runs/{RunId:guid}"] = "", + + // Anonymous pages (no [Authorize]) + ["/account/login"] = null, + ["/account/mfa-verify"] = null, + ["/account/mfa-recovery"] = null, + ["/account/access-denied"] = null, + ["/account/logout"] = null, + ["/Error"] = null, + }; + + [TestMethod] + public void AllPages_HaveCorrectAuthorization( ) { + Assembly serverAssembly = typeof( Werkr.Server.Identity.WerkrCookieAuthEvents ).Assembly; + + List pageTypes = [.. serverAssembly + .GetTypes( ) + .Where( t => t.GetCustomAttribute( ) is not null ) + .Where( t => typeof( ComponentBase ).IsAssignableFrom( t ) )]; + + Assert.IsNotEmpty( pageTypes, "Should discover at least one Blazor page." ); + + List errors = []; + + foreach (Type page in pageTypes) { + RouteAttribute route = page.GetCustomAttribute( )!; + AuthorizeAttribute? auth = page.GetCustomAttribute( ); + + if (!s_expectedAuthorization.TryGetValue( route.Template, out string? expectedRoles )) { + // Page not in our map — skip (could be added later) + continue; + } + + if (expectedRoles is null) { + // Should be anonymous (no [Authorize]) + if (auth is not null) { + errors.Add( $"Page '{route.Template}' ({page.Name}) should be anonymous but has [Authorize]." ); + } + } else if (expectedRoles == "") { + // Should have [Authorize] with no specific roles + if (auth is null) { + errors.Add( $"Page '{route.Template}' ({page.Name}) should require authentication but lacks [Authorize]." ); + } + } else { + // Should have [Authorize(Roles = "...")] + if (auth is null) { + errors.Add( $"Page '{route.Template}' ({page.Name}) should have [Authorize(Roles=\"{expectedRoles}\")] but lacks [Authorize]." ); + } else if (auth.Roles != expectedRoles) { + errors.Add( $"Page '{route.Template}' ({page.Name}) expected Roles=\"{expectedRoles}\" but got Roles=\"{auth.Roles}\"." ); + } + } + } + + if (errors.Count > 0) { + Assert.Fail( "Authorization errors:\n" + string.Join( "\n", errors ) ); + } + } + + [TestMethod] + public void AdminPages_RequireAdminRole( ) { + Assembly serverAssembly = typeof( Werkr.Server.Identity.WerkrCookieAuthEvents ).Assembly; + + string[] adminRoutes = [ + "/agents", + "/agents/{AgentId:guid}", + "/agents/register", + "/admin/users", + "/admin/users/create", + "/admin/users/{UserId}", + "/settings" + ]; + + List pageTypes = [.. serverAssembly + .GetTypes( ) + .Where( t => t.GetCustomAttribute( ) is not null ) + .Where( t => typeof( ComponentBase ).IsAssignableFrom( t ) )]; + + foreach (string route in adminRoutes) { + Type? pageType = pageTypes.FirstOrDefault( t => + t.GetCustomAttribute( )!.Template == route ); + + Assert.IsNotNull( pageType, $"Page with route '{route}' should exist." ); + + AuthorizeAttribute? auth = pageType.GetCustomAttribute( ); + + Assert.IsNotNull( auth, $"Page '{route}' ({pageType.Name}) must have [Authorize]." ); + Assert.IsNotNull( auth.Roles, $"Page '{route}' ({pageType.Name}) must specify roles." ); + Assert.Contains( "Admin", auth.Roles, $"Page '{route}' ({pageType.Name}) must include Admin role." ); + } + } +} diff --git a/src/Test/Werkr.Tests.Server/Identity/ApiKeyServiceTests.cs b/src/Test/Werkr.Tests.Server/Identity/ApiKeyServiceTests.cs new file mode 100644 index 0000000..d0193c2 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Identity/ApiKeyServiceTests.cs @@ -0,0 +1,174 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Data.Identity; +using Werkr.Data.Identity.Entities; +using Werkr.Server.Identity; + +namespace Werkr.Tests.Server.Identity; + +/// +/// Unit tests for . +/// Uses an in-memory EF Core database. +/// +[TestClass] +public class ApiKeyServiceTests { + public TestContext TestContext { get; set; } = null!; + + private WerkrIdentityDbContext _dbContext = null!; + private ApiKeyService _service = null!; + + [TestInitialize] + public void TestInit( ) { + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseInMemoryDatabase( $"ApiKeyTests_{Guid.NewGuid( )}" ) + .Options; + _dbContext = new WerkrIdentityDbContext( options ); + ILogger logger = NullLogger.Instance; + _service = new ApiKeyService( _dbContext, logger ); + } + + [TestCleanup] + public void TestCleanup( ) => _dbContext.Dispose( ); + + [TestMethod] + public async Task CreateAsync_ReturnsRawKeyWithPrefix( ) { + (ApiKey entity, string rawKey) = await _service.CreateAsync( + "Test Key", "Admin", "user-1", ct: TestContext.CancellationToken ); + + Assert.IsNotNull( rawKey ); + Assert.StartsWith( "wk_", rawKey, "Raw key should start with 'wk_' prefix." ); + Assert.AreEqual( "Test Key", entity.Name ); + Assert.AreEqual( "Admin", entity.Role ); + Assert.AreEqual( "user-1", entity.CreatedByUserId ); + Assert.IsFalse( entity.IsRevoked ); + Assert.IsNotNull( entity.KeyHash ); + Assert.AreNotEqual( string.Empty, entity.KeyHash ); + } + + [TestMethod] + public async Task CreateAsync_StoresKeyPrefixFromRawKey( ) { + (ApiKey entity, string rawKey) = await _service.CreateAsync( + "Prefix Key", "Operator", "user-1", ct: TestContext.CancellationToken ); + + string expectedPrefix = rawKey[..Math.Min( 12, rawKey.Length )]; + Assert.AreEqual( expectedPrefix, entity.KeyPrefix ); + } + + [TestMethod] + public async Task ValidateAsync_ValidKey_ReturnsEntity( ) { + (ApiKey entity, string rawKey) = await _service.CreateAsync( + "Valid Key", "Admin", "user-1", ct: TestContext.CancellationToken ); + + ApiKey? result = await _service.ValidateAsync( rawKey, TestContext.CancellationToken ); + + Assert.IsNotNull( result ); + Assert.AreEqual( entity.Id, result.Id ); + } + + [TestMethod] + public async Task ValidateAsync_InvalidKey_ReturnsNull( ) { + ApiKey? result = await _service.ValidateAsync( + "wk_invalid_key_1234", TestContext.CancellationToken ); + + Assert.IsNull( result ); + } + + [TestMethod] + public async Task ValidateAsync_RevokedKey_ReturnsNull( ) { + (ApiKey _, string rawKey) = await _service.CreateAsync( + "Revoked Key", "Admin", "user-1", ct: TestContext.CancellationToken ); + + // Validate once (should succeed) + Assert.IsNotNull( await _service.ValidateAsync( rawKey, TestContext.CancellationToken ) ); + + // Revoke + ApiKey revokedEntity = _dbContext.ApiKeys.First( ); + revokedEntity.IsRevoked = true; + _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); + + // Validate again (should fail) + Assert.IsNull( await _service.ValidateAsync( rawKey, TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task ValidateAsync_ExpiredKey_ReturnsNull( ) { + (ApiKey _, string rawKey) = await _service.CreateAsync( + "Expired Key", "Admin", "user-1", + expiresUtc: DateTime.UtcNow.AddMinutes( -5 ), + ct: TestContext.CancellationToken ); + + Assert.IsNull( await _service.ValidateAsync( rawKey, TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task ValidateAsync_UpdatesLastUsedUtc( ) { + (ApiKey entity, string rawKey) = await _service.CreateAsync( + "Used Key", "Admin", "user-1", ct: TestContext.CancellationToken ); + + Assert.IsNull( entity.LastUsedUtc ); + + _ = await _service.ValidateAsync( rawKey, TestContext.CancellationToken ); + + ApiKey? updated = await _dbContext.ApiKeys.FindAsync( [entity.Id], TestContext.CancellationToken ); + Assert.IsNotNull( updated?.LastUsedUtc ); + } + + [TestMethod] + public async Task RevokeAsync_ExistingKey_ReturnsTrue( ) { + (ApiKey entity, string _) = await _service.CreateAsync( + "To Revoke", "Admin", "user-1", ct: TestContext.CancellationToken ); + + bool result = await _service.RevokeAsync( entity.Id, TestContext.CancellationToken ); + + Assert.IsTrue( result ); + + ApiKey? revoked = await _dbContext.ApiKeys.FindAsync( [entity.Id], TestContext.CancellationToken ); + Assert.IsTrue( revoked?.IsRevoked ); + } + + [TestMethod] + public async Task RevokeAsync_NonExistentKey_ReturnsFalse( ) { + bool result = await _service.RevokeAsync( Guid.NewGuid( ), TestContext.CancellationToken ); + Assert.IsFalse( result ); + } + + [TestMethod] + public async Task GetAllAsync_ReturnsCreatedKeys( ) { + _ = await _service.CreateAsync( "Key1", "Admin", "user-1", ct: TestContext.CancellationToken ); + _ = await _service.CreateAsync( "Key2", "Operator", "user-2", ct: TestContext.CancellationToken ); + + IReadOnlyList keys = await _service.GetAllAsync( TestContext.CancellationToken ); + + Assert.HasCount( 2, keys ); + } + + [TestMethod] + public async Task GetByUserAsync_FiltersCorrectly( ) { + _ = await _service.CreateAsync( "User1 Key", "Admin", "user-1", ct: TestContext.CancellationToken ); + _ = await _service.CreateAsync( "User2 Key", "Operator", "user-2", ct: TestContext.CancellationToken ); + + IReadOnlyList user1Keys = await _service.GetByUserAsync( "user-1", TestContext.CancellationToken ); + IReadOnlyList user2Keys = await _service.GetByUserAsync( "user-2", TestContext.CancellationToken ); + + Assert.HasCount( 1, user1Keys ); + Assert.AreEqual( "User1 Key", user1Keys[0].Name ); + Assert.HasCount( 1, user2Keys ); + Assert.AreEqual( "User2 Key", user2Keys[0].Name ); + } + + [TestMethod] + public async Task CreateAsync_GeneratesUniqueKeys( ) { + (_, string rawKey1) = await _service.CreateAsync( "Key A", "Admin", "user-1", ct: TestContext.CancellationToken ); + (_, string rawKey2) = await _service.CreateAsync( "Key B", "Admin", "user-1", ct: TestContext.CancellationToken ); + + Assert.AreNotEqual( rawKey1, rawKey2 ); + } + + [TestMethod] + public async Task ValidateAsync_NullOrEmpty_ReturnsNull( ) { + Assert.IsNull( await _service.ValidateAsync( null!, TestContext.CancellationToken ) ); + Assert.IsNull( await _service.ValidateAsync( "", TestContext.CancellationToken ) ); + Assert.IsNull( await _service.ValidateAsync( " ", TestContext.CancellationToken ) ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Identity/IdentityFlowTests.cs b/src/Test/Werkr.Tests.Server/Identity/IdentityFlowTests.cs new file mode 100644 index 0000000..8b6ac15 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Identity/IdentityFlowTests.cs @@ -0,0 +1,247 @@ +using System.Security.Claims; + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using Werkr.Data.Identity; +using Werkr.Data.Identity.Entities; +using Werkr.Server.Identity; + +namespace Werkr.Tests.Server.Identity; + +/// +/// Tests for the forced password change, MFA enrollment, MFA verification, +/// and admin MFA policy enforcement flows (§3.12.2). +/// +[TestClass] +public class IdentityFlowTests { + [TestMethod] + public async Task ForcedPasswordChange_RedirectsToChangePassword( ) { + ServiceProvider sp = BuildIdentityServiceProvider( out UserManager um ); + + WerkrUser user = await CreateUserAsync( um, + "force@local", changePassword: true, enabled: true ); + + CookieValidatePrincipalContext ctx = BuildContext( sp, BuildPrincipal( user.Id ), "/" ); + await new WerkrCookieAuthEvents( ).ValidatePrincipal( ctx ); + + Assert.AreEqual( "/account/change-password", + ctx.HttpContext.Response.Headers.Location.ToString( ) ); + } + + [TestMethod] + public async Task ForcedPasswordChange_CannotAccessOtherPages( ) { + ServiceProvider sp = BuildIdentityServiceProvider( out UserManager um ); + + WerkrUser user = await CreateUserAsync( um, + "force2@local", changePassword: true, enabled: true ); + + CookieValidatePrincipalContext ctx = BuildContext( sp, BuildPrincipal( user.Id ), "/agents" ); + await new WerkrCookieAuthEvents( ).ValidatePrincipal( ctx ); + + Assert.AreEqual( "/account/change-password", + ctx.HttpContext.Response.Headers.Location.ToString( ) ); + Assert.IsTrue( ctx.ShouldRenew ); + } + + [TestMethod] + public async Task ChangePassword_SuccessfulChange_ClearsFlag( ) { + _ = BuildIdentityServiceProvider( out UserManager um ); + + WerkrUser user = await CreateUserAsync( um, + "pwd-clear@local", changePassword: true, enabled: true ); + + // Simulate a successful password change + IdentityResult result = await um.ChangePasswordAsync( user, "TestPassword123!", "NewStrongP@$$w0rd" ); + + Assert.IsTrue( result.Succeeded, "Password change should succeed." ); + + user.ChangePassword = false; + _ = await um.UpdateAsync( user ); + + WerkrUser? reloaded = await um.FindByEmailAsync( "pwd-clear@local" ); + Assert.IsFalse( reloaded!.ChangePassword, + "ChangePassword flag should be cleared after successful change." ); + } + + [TestMethod] + public async Task ChangePassword_WeakPassword_ShowsError( ) { + _ = BuildIdentityServiceProvider( out UserManager um ); + + WerkrUser user = await CreateUserAsync( um, + "pwd-weak@local", changePassword: true, enabled: true ); + + // Attempt with a weak password that violates OWASP policy (< 12 chars, no special) + IdentityResult result = await um.ChangePasswordAsync( user, "TestPassword123!", "short" ); + + Assert.IsFalse( result.Succeeded, + "Password change with weak password should fail." ); + Assert.IsTrue( result.Errors.Any( ), + "Identity errors should be returned for policy violation." ); + } + + [TestMethod] + public async Task MfaEnrollment_GeneratesAuthenticatorKey( ) { + _ = BuildIdentityServiceProvider( out UserManager um ); + + WerkrUser user = await CreateUserAsync( um, + "mfa-gen@local", enabled: true ); + + string? key = await um.GetAuthenticatorKeyAsync( user ); + if (string.IsNullOrWhiteSpace( key )) { + _ = await um.ResetAuthenticatorKeyAsync( user ); + key = await um.GetAuthenticatorKeyAsync( user ); + } + + Assert.IsFalse( string.IsNullOrWhiteSpace( key ), + "Authenticator key should be generated for MFA enrollment." ); + } + + [TestMethod] + public async Task MfaEnrollment_ValidCode_EnablesTwoFactor( ) { + _ = BuildIdentityServiceProvider( out UserManager um ); + + WerkrUser user = await CreateUserAsync( um, + "mfa-enable@local", enabled: true ); + + Assert.IsFalse( await um.GetTwoFactorEnabledAsync( user ) ); + + IdentityResult result = await um.SetTwoFactorEnabledAsync( user, true ); + + Assert.IsTrue( result.Succeeded, "Enabling 2FA should succeed." ); + Assert.IsTrue( await um.GetTwoFactorEnabledAsync( user ), + "TwoFactorEnabled should be true after enrollment." ); + } + + [TestMethod] + public async Task MfaVerify_InvalidCode_FailsVerification( ) { + _ = BuildIdentityServiceProvider( out UserManager um ); + + WerkrUser user = await CreateUserAsync( um, + "mfa-invalid@local", enabled: true ); + + _ = await um.ResetAuthenticatorKeyAsync( user ); + + bool isValid = await um.VerifyTwoFactorTokenAsync( + user, + um.Options.Tokens.AuthenticatorTokenProvider, + "000000" ); + + Assert.IsFalse( isValid, "Invalid TOTP code should fail verification." ); + } + + [TestMethod] + public async Task MfaVerify_RecoveryCode_ConsumesCode( ) { + _ = BuildIdentityServiceProvider( out UserManager um ); + + WerkrUser user = await CreateUserAsync( um, + "mfa-recovery@local", enabled: true ); + + _ = await um.SetTwoFactorEnabledAsync( user, true ); + + IEnumerable? codes = await um.GenerateNewTwoFactorRecoveryCodesAsync( user, 5 ); + Assert.IsNotNull( codes ); + + string firstCode = codes.First( ); + + int countBefore = await um.CountRecoveryCodesAsync( user ); + IdentityResult redeemResult = await um.RedeemTwoFactorRecoveryCodeAsync( user, firstCode ); + int countAfter = await um.CountRecoveryCodesAsync( user ); + + Assert.IsTrue( redeemResult.Succeeded, "Recovery code redemption should succeed." ); + Assert.AreEqual( countBefore - 1, countAfter, + "Recovery code count should decrease after use." ); + } + + [TestMethod] + public async Task AdminMfaPolicy_EnforcesEnrollment( ) { + ServiceProvider sp = BuildIdentityServiceProvider( out UserManager um ); + + WerkrUser user = await CreateUserAsync( um, + "admin-mfa@local", enabled: true, requires2FA: true ); + + CookieValidatePrincipalContext ctx = BuildContext( sp, BuildPrincipal( user.Id ), "/" ); + await new WerkrCookieAuthEvents( ).ValidatePrincipal( ctx ); + + Assert.AreEqual( "/account/manage/mfa?required=true", + ctx.HttpContext.Response.Headers.Location.ToString( ), + "Admin with Requires2FA but no TwoFactorEnabled should be redirected to MFA enrollment." ); + } + + // ── helpers ────────────────────────────────────────────────────────── + + private static async Task CreateUserAsync( + UserManager um, + string email, + bool enabled = true, + bool changePassword = false, + bool requires2FA = false ) { + WerkrUser user = new( ) { + UserName = email, + Email = email, + Name = email.Split( '@' )[0], + Enabled = enabled, + ChangePassword = changePassword, + Requires2FA = requires2FA, + TwoFactorEnabled = false, + EmailConfirmed = true + }; + + IdentityResult result = await um.CreateAsync( user, "TestPassword123!" ); + Assert.IsTrue( result.Succeeded, $"User creation failed: {string.Join( "; ", result.Errors.Select( e => e.Description ) )}" ); + return user; + } + + private static ServiceProvider BuildIdentityServiceProvider( out UserManager userManager ) { + ServiceCollection services = new( ); + string dbName = $"IdentityFlowTests_{Guid.NewGuid( )}"; + + _ = services.AddDbContext( options => + options.UseInMemoryDatabase( dbName ) ); + + _ = services.AddIdentity( + Werkr.Data.Identity.Extensions.IdentityExtensions.ConfigureIdentityOptions ) + .AddEntityFrameworkStores( ) + .AddDefaultTokenProviders( ); + + _ = services.AddLogging( ); + + ServiceProvider provider = services.BuildServiceProvider( ); + userManager = provider.GetRequiredService>( ); + return provider; + } + + private static CookieValidatePrincipalContext BuildContext( + IServiceProvider serviceProvider, + ClaimsPrincipal principal, + string path ) { + DefaultHttpContext httpContext = new( ) { + RequestServices = serviceProvider + }; + + httpContext.Request.Path = path; + + AuthenticationScheme scheme = new( + CookieAuthenticationDefaults.AuthenticationScheme, + CookieAuthenticationDefaults.AuthenticationScheme, + typeof( CookieAuthenticationHandler ) ); + + CookieAuthenticationOptions options = new( ); + AuthenticationProperties properties = new( ); + AuthenticationTicket ticket = new( principal, properties, scheme.Name ); + + return new CookieValidatePrincipalContext( httpContext, scheme, options, ticket ); + } + + private static ClaimsPrincipal BuildPrincipal( string userId ) { + ClaimsIdentity identity = new( + [new Claim( ClaimTypes.NameIdentifier, userId )], + CookieAuthenticationDefaults.AuthenticationScheme ); + + return new ClaimsPrincipal( identity ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Identity/IdentityOptionsTests.cs b/src/Test/Werkr.Tests.Server/Identity/IdentityOptionsTests.cs new file mode 100644 index 0000000..475671e --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Identity/IdentityOptionsTests.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Identity; + +using Werkr.Data.Identity.Extensions; + +namespace Werkr.Tests.Server.Identity; + +/// +/// Verifies that Identity is configured with NIST-aligned defaults. +/// +[TestClass] +public class IdentityOptionsTests { + private readonly IdentityOptions _options = new( ); + + [TestInitialize] + public void TestInit( ) { + IdentityExtensions.ConfigureIdentityOptions( _options ); + } + + [TestMethod] + public void Password_RequiresMinLength12( ) { + Assert.AreEqual( 12, _options.Password.RequiredLength ); + } + + [TestMethod] + public void Password_DoesNotRequireDigit( ) { + Assert.IsFalse( _options.Password.RequireDigit ); + } + + [TestMethod] + public void Password_DoesNotRequireLowercase( ) { + Assert.IsFalse( _options.Password.RequireLowercase ); + } + + [TestMethod] + public void Password_DoesNotRequireUppercase( ) { + Assert.IsFalse( _options.Password.RequireUppercase ); + } + + [TestMethod] + public void Password_DoesNotRequireNonAlphanumeric( ) { + Assert.IsFalse( _options.Password.RequireNonAlphanumeric ); + } + + [TestMethod] + public void Password_RequiresUniqueChars1( ) { + Assert.AreEqual( 1, _options.Password.RequiredUniqueChars ); + } + + [TestMethod] + public void Lockout_15MinuteTimeSpan( ) { + Assert.AreEqual( TimeSpan.FromMinutes( 15 ), _options.Lockout.DefaultLockoutTimeSpan ); + } + + [TestMethod] + public void Lockout_MaxFailedAttempts5( ) { + Assert.AreEqual( 5, _options.Lockout.MaxFailedAccessAttempts ); + } + + [TestMethod] + public void Lockout_AllowedForNewUsers( ) { + Assert.IsTrue( _options.Lockout.AllowedForNewUsers ); + } + + [TestMethod] + public void User_RequiresUniqueEmail( ) { + Assert.IsTrue( _options.User.RequireUniqueEmail ); + } + + [TestMethod] + public void SignIn_DoesNotRequireConfirmedAccount( ) { + Assert.IsFalse( _options.SignIn.RequireConfirmedAccount ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Identity/IdentitySeederTests.cs b/src/Test/Werkr.Tests.Server/Identity/IdentitySeederTests.cs new file mode 100644 index 0000000..d4d6041 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Identity/IdentitySeederTests.cs @@ -0,0 +1,134 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Data.Identity; +using Werkr.Data.Identity.Entities; +using Werkr.Data.Identity.Roles; +using Werkr.Server.Identity; +using Werkr.Server.Services; + +namespace Werkr.Tests.Server.Identity; + +[TestClass] +public class IdentitySeederTests { + public TestContext TestContext { get; set; } = null!; + private ServiceProvider _provider = null!; + + [TestInitialize] + public async Task TestInit( ) { + ServiceCollection services = new( ); + + // Use a unique in-memory database per test to avoid cross-test contamination + string dbName = $"IdentitySeederTests_{Guid.NewGuid( )}"; + _ = services.AddDbContext( options => + options.UseInMemoryDatabase( dbName ) ); + + _ = services.AddIdentity( + Werkr.Data.Identity.Extensions.IdentityExtensions.ConfigureIdentityOptions ) + .AddEntityFrameworkStores( ) + .AddDefaultTokenProviders( ); + + _ = services.AddLogging( b => b.AddProvider( NullLoggerProvider.Instance ) ); + + _ = services.AddSingleton( + new ConfigurationBuilder( ).AddInMemoryCollection( ).Build( ) ); + + // ServerConfigCache uses WerkrIdentityDbContext for config persistence + _ = services.AddSingleton( ); + + _provider = services.BuildServiceProvider( ); + + // Initialize the config cache (creates the default config row) + ServerConfigCache configCache = _provider.GetRequiredService( ); + await configCache.InitializeAsync( TestContext.CancellationToken ); + } + + [TestCleanup] + public void TestCleanup( ) { + _provider.Dispose( ); + } + + [TestMethod] + public async Task SeedAsync_CreatesDefaultRoles( ) { + await IdentitySeeder.SeedAsync( _provider ); + + using IServiceScope scope = _provider.CreateScope( ); + RoleManager roleManager = scope.ServiceProvider + .GetRequiredService>( ); + + foreach (string role in Enum.GetNames( )) { + Assert.IsTrue( await roleManager.RoleExistsAsync( role ), + $"Role '{role}' should exist after seeding." ); + } + } + + [TestMethod] + public async Task SeedAsync_CreatesDefaultAdminUser( ) { + await IdentitySeeder.SeedAsync( _provider ); + + using IServiceScope scope = _provider.CreateScope( ); + UserManager userManager = scope.ServiceProvider + .GetRequiredService>( ); + + WerkrUser? admin = await userManager.FindByEmailAsync( "admin@werkr.local" ); + + Assert.IsNotNull( admin, "Default admin user should be created." ); + Assert.AreEqual( "Default Admin", admin.Name ); + Assert.IsTrue( admin.Enabled, "Admin should be enabled." ); + Assert.IsTrue( admin.ChangePassword, "Admin should be flagged for password change." ); + Assert.IsTrue( admin.Requires2FA, "Admin should require MFA enrollment." ); + Assert.IsTrue( admin.EmailConfirmed, "Admin email should be confirmed." ); + } + + [TestMethod] + public async Task SeedAsync_AdminHasAdminRole( ) { + await IdentitySeeder.SeedAsync( _provider ); + + using IServiceScope scope = _provider.CreateScope( ); + UserManager userManager = scope.ServiceProvider + .GetRequiredService>( ); + + WerkrUser? admin = await userManager.FindByEmailAsync( "admin@werkr.local" ); + Assert.IsNotNull( admin ); + + IList roles = await userManager.GetRolesAsync( admin ); + + Assert.Contains( DefaultRoles.Admin.ToString( ), roles, + "Admin user should be in Admin role." ); + } + + [TestMethod] + public async Task SeedAsync_SecondCallDoesNotCreateDuplicateAdmin( ) { + await IdentitySeeder.SeedAsync( _provider ); + await IdentitySeeder.SeedAsync( _provider ); + + using IServiceScope scope = _provider.CreateScope( ); + UserManager userManager = scope.ServiceProvider + .GetRequiredService>( ); + + IList admins = await userManager.GetUsersInRoleAsync( + DefaultRoles.Admin.ToString( ) ); + + Assert.HasCount( 1, admins, "Should have exactly one admin after double-seed." ); + } + + [TestMethod] + public async Task SeedAsync_SecondCallDoesNotCreateDuplicateRoles( ) { + await IdentitySeeder.SeedAsync( _provider ); + await IdentitySeeder.SeedAsync( _provider ); + + using IServiceScope scope = _provider.CreateScope( ); + RoleManager roleManager = scope.ServiceProvider + .GetRequiredService>( ); + + // Each role should exist exactly once + foreach (string role in Enum.GetNames( )) { + Assert.IsTrue( await roleManager.RoleExistsAsync( role ), + $"Role '{role}' should still exist after double-seed." ); + } + } +} diff --git a/src/Test/Werkr.Tests.Server/Identity/JwtTokenServiceTests.cs b/src/Test/Werkr.Tests.Server/Identity/JwtTokenServiceTests.cs new file mode 100644 index 0000000..df14d93 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Identity/JwtTokenServiceTests.cs @@ -0,0 +1,191 @@ +using System.Security.Claims; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Werkr.Data.Identity.Entities; +using Werkr.Server.Identity; + +namespace Werkr.Tests.Server.Identity; + +/// +/// Unit tests for . +/// +[TestClass] +public class JwtTokenServiceTests { + private const string TestSigningKey = "test-signing-key-that-is-at-least-32-characters-long!"; + private const string TestIssuer = "werkr-test-issuer"; + private const string TestAudience = "werkr-test-audience"; + + private static JwtTokenService CreateService( string? signingKey = null, string? issuer = null, string? audience = null ) { + Dictionary config = new( ) { + ["Jwt:SigningKey"] = signingKey ?? TestSigningKey, + ["Jwt:Issuer"] = issuer ?? TestIssuer, + ["Jwt:Audience"] = audience ?? TestAudience, + ["Jwt:TokenLifetimeMinutes"] = "15", + }; + + IConfiguration configuration = new ConfigurationBuilder( ) + .AddInMemoryCollection( config ) + .Build( ); + + return new JwtTokenService( configuration, NullLogger.Instance ); + } + + private static ApiKey CreateTestApiKey( ) => new( ) { + Id = Guid.NewGuid( ), + KeyHash = "test-hash", + KeyPrefix = "wk_test_1234", + Name = "Test Key", + Role = "Admin", + CreatedByUserId = "user-123", + CreatedUtc = DateTime.UtcNow, + }; + + [TestMethod] + public void Constructor_ThrowsWhenSigningKeyMissing( ) { + Dictionary config = new( ) { + ["Jwt:Issuer"] = TestIssuer, + }; + + IConfiguration configuration = new ConfigurationBuilder( ) + .AddInMemoryCollection( config ) + .Build( ); + + _ = Assert.ThrowsExactly( + ( ) => new JwtTokenService( configuration, NullLogger.Instance ) ); + } + + [TestMethod] + public void Constructor_ThrowsWhenSigningKeyTooShort( ) { + Dictionary config = new( ) { + ["Jwt:SigningKey"] = "short", + ["Jwt:Issuer"] = TestIssuer, + }; + + IConfiguration configuration = new ConfigurationBuilder( ) + .AddInMemoryCollection( config ) + .Build( ); + + _ = Assert.ThrowsExactly( + ( ) => new JwtTokenService( configuration, NullLogger.Instance ) ); + } + + [TestMethod] + public async Task GenerateToken_ProducesValidJwt( ) { + JwtTokenService service = CreateService( ); + ApiKey apiKey = CreateTestApiKey( ); + + string token = service.GenerateToken( apiKey ); + + Assert.IsFalse( string.IsNullOrWhiteSpace( token ) ); + + // Parse and validate the token + JsonWebTokenHandler handler = new( ); + TokenValidationParameters tvp = service.GetValidationParameters( ); + + TokenValidationResult result = await handler.ValidateTokenAsync( token, tvp ); + + Assert.IsTrue( result.IsValid, $"Token validation failed: {result.Exception?.Message}" ); + Assert.IsNotNull( result.ClaimsIdentity ); + } + + [TestMethod] + public void GenerateToken_ContainsExpectedClaims( ) { + JwtTokenService service = CreateService( ); + ApiKey apiKey = CreateTestApiKey( ); + + string token = service.GenerateToken( apiKey ); + + JsonWebTokenHandler handler = new( ); + JsonWebToken jwt = handler.ReadJsonWebToken( token ); + + Assert.AreEqual( apiKey.CreatedByUserId, + jwt.Claims.First( c => c.Type is ClaimTypes.NameIdentifier + or "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" ).Value ); + Assert.AreEqual( apiKey.Role, + jwt.Claims.First( c => c.Type is ClaimTypes.Role + or "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" ).Value ); + Assert.AreEqual( apiKey.Id.ToString( ), + jwt.Claims.First( c => c.Type == "api_key_id" ).Value ); + Assert.AreEqual( apiKey.Name, + jwt.Claims.First( c => c.Type == "api_key_name" ).Value ); + Assert.IsFalse( string.IsNullOrWhiteSpace( + jwt.Claims.First( c => c.Type == JwtRegisteredClaimNames.Jti ).Value ) ); + } + + [TestMethod] + public void GenerateToken_SetsCorrectIssuerAndAudience( ) { + JwtTokenService service = CreateService( ); + ApiKey apiKey = CreateTestApiKey( ); + + string token = service.GenerateToken( apiKey ); + JsonWebToken jwt = new JsonWebTokenHandler( ).ReadJsonWebToken( token ); + + Assert.AreEqual( TestIssuer, jwt.Issuer ); + Assert.IsTrue( jwt.Audiences.Contains( TestAudience ) ); + } + + [TestMethod] + public void GenerateToken_SetsExpirationInFuture( ) { + JwtTokenService service = CreateService( ); + ApiKey apiKey = CreateTestApiKey( ); + + string token = service.GenerateToken( apiKey ); + JsonWebToken jwt = new JsonWebTokenHandler( ).ReadJsonWebToken( token ); + + Assert.IsGreaterThan( DateTime.UtcNow, jwt.ValidTo, "Token should expire in the future." ); + Assert.IsLessThan( DateTime.UtcNow.AddMinutes( 20 ), jwt.ValidTo, "Token should expire within 20 minutes." ); + } + + [TestMethod] + public void GenerateToken_EachTokenHasUniqueJti( ) { + JwtTokenService service = CreateService( ); + ApiKey apiKey = CreateTestApiKey( ); + + string token1 = service.GenerateToken( apiKey ); + string token2 = service.GenerateToken( apiKey ); + + JsonWebTokenHandler handler = new( ); + string jti1 = handler.ReadJsonWebToken( token1 ).Claims.First( c => c.Type == JwtRegisteredClaimNames.Jti ).Value; + string jti2 = handler.ReadJsonWebToken( token2 ).Claims.First( c => c.Type == JwtRegisteredClaimNames.Jti ).Value; + + Assert.AreNotEqual( jti1, jti2, "Each token should have a unique JTI." ); + } + + [TestMethod] + public void GetValidationParameters_ReturnsProperlyConfigure( ) { + JwtTokenService service = CreateService( ); + + TokenValidationParameters tvp = service.GetValidationParameters( ); + + Assert.IsTrue( tvp.ValidateIssuerSigningKey ); + Assert.IsTrue( tvp.ValidateIssuer ); + Assert.IsTrue( tvp.ValidateAudience ); + Assert.IsTrue( tvp.ValidateLifetime ); + Assert.AreEqual( TestIssuer, tvp.ValidIssuer ); + Assert.AreEqual( TestAudience, tvp.ValidAudience ); + Assert.IsNotNull( tvp.IssuerSigningKey ); + } + + [TestMethod] + public void GenerateToken_DefaultsUsedWhenConfigMissing( ) { + Dictionary config = new( ) { + ["Jwt:SigningKey"] = TestSigningKey, + // Issuer and Audience not set — should use defaults + }; + + IConfiguration configuration = new ConfigurationBuilder( ) + .AddInMemoryCollection( config ) + .Build( ); + + JwtTokenService service = new( configuration, NullLogger.Instance ); + ApiKey apiKey = CreateTestApiKey( ); + + string token = service.GenerateToken( apiKey ); + JsonWebToken jwt = new JsonWebTokenHandler( ).ReadJsonWebToken( token ); + + Assert.AreEqual( "werkr-api", jwt.Issuer ); + Assert.IsTrue( jwt.Audiences.Contains( "werkr" ) ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Identity/PermissionServiceTests.cs b/src/Test/Werkr.Tests.Server/Identity/PermissionServiceTests.cs new file mode 100644 index 0000000..85a2a22 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Identity/PermissionServiceTests.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +using Werkr.Common.Auth; +using Werkr.Data; +using Werkr.Data.Identity; +using Werkr.Data.Identity.Entities; +using Werkr.Data.Identity.Services; +using Werkr.Server.Identity; +using Werkr.Server.Services; + +namespace Werkr.Tests.Server.Identity; + +/// +/// Unit tests for and the seeded role-permission mappings. +/// +[TestClass] +public class PermissionServiceTests { + public TestContext TestContext { get; set; } = null!; + + private ServiceProvider _provider = null!; + + [TestInitialize] + public async Task TestInit( ) { + ServiceCollection services = new( ); + string dbName = $"PermSvcTests_{Guid.NewGuid( )}"; + _ = services.AddDbContext( options => + options.UseInMemoryDatabase( dbName ) ); + + // ServerConfigCache requires WerkrDbContext + _ = services.AddDbContext( options => + options.UseInMemoryDatabase( dbName + "_werkr" ) ); + + _ = services.AddIdentity( + Werkr.Data.Identity.Extensions.IdentityExtensions.ConfigureIdentityOptions ) + .AddEntityFrameworkStores( ) + .AddDefaultTokenProviders( ); + _ = services.AddLogging( b => b.AddProvider( NullLoggerProvider.Instance ) ); + _ = services.AddSingleton( + new ConfigurationBuilder( ).AddInMemoryCollection( ).Build( ) ); + _ = services.AddSingleton( ); + _ = services.AddScoped( ); + _provider = services.BuildServiceProvider( ); + + // Initialize the config cache (creates the default config row) + ServerConfigCache configCache = _provider.GetRequiredService( ); + await configCache.InitializeAsync( TestContext.CancellationToken ); + } + + [TestCleanup] + public void TestCleanup( ) => _provider.Dispose( ); + + [TestMethod] + public async Task Admin_HasAllPermissions( ) { + await IdentitySeeder.SeedAsync( _provider ); + using IServiceScope scope = _provider.CreateScope( ); + IPermissionService svc = scope.ServiceProvider.GetRequiredService( ); + + IReadOnlySet perms = await svc.GetPermissionsAsync( + ["Admin"], TestContext.CancellationToken ); + + foreach (Permission p in Enum.GetValues( )) { + Assert.Contains( p, perms, $"Admin should have permission {p}." ); + } + } + + [TestMethod] + public async Task Operator_HasReadAndExecute( ) { + await IdentitySeeder.SeedAsync( _provider ); + using IServiceScope scope = _provider.CreateScope( ); + IPermissionService svc = scope.ServiceProvider.GetRequiredService( ); + + Assert.IsTrue( await svc.HasPermissionAsync( ["Operator"], Permission.Read, TestContext.CancellationToken ) ); + Assert.IsTrue( await svc.HasPermissionAsync( ["Operator"], Permission.Execute, TestContext.CancellationToken ) ); + Assert.IsFalse( await svc.HasPermissionAsync( ["Operator"], Permission.Admin, TestContext.CancellationToken ) ); + Assert.IsFalse( await svc.HasPermissionAsync( ["Operator"], Permission.Delete, TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task Viewer_HasOnlyRead( ) { + await IdentitySeeder.SeedAsync( _provider ); + using IServiceScope scope = _provider.CreateScope( ); + IPermissionService svc = scope.ServiceProvider.GetRequiredService( ); + + Assert.IsTrue( await svc.HasPermissionAsync( ["Viewer"], Permission.Read, TestContext.CancellationToken ) ); + Assert.IsFalse( await svc.HasPermissionAsync( ["Viewer"], Permission.Create, TestContext.CancellationToken ) ); + Assert.IsFalse( await svc.HasPermissionAsync( ["Viewer"], Permission.Execute, TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task UnknownRole_HasNoPermissions( ) { + await IdentitySeeder.SeedAsync( _provider ); + using IServiceScope scope = _provider.CreateScope( ); + IPermissionService svc = scope.ServiceProvider.GetRequiredService( ); + + IReadOnlySet perms = await svc.GetPermissionsAsync( + ["NonExistentRole"], TestContext.CancellationToken ); + Assert.IsEmpty( perms ); + } + + [TestMethod] + public async Task EmptyRoles_ReturnsFalse( ) { + await IdentitySeeder.SeedAsync( _provider ); + using IServiceScope scope = _provider.CreateScope( ); + IPermissionService svc = scope.ServiceProvider.GetRequiredService( ); + + Assert.IsFalse( await svc.HasPermissionAsync( + [], Permission.Read, TestContext.CancellationToken ) ); + } + + [TestMethod] + public async Task MultipleRoles_UnionPermissions( ) { + await IdentitySeeder.SeedAsync( _provider ); + using IServiceScope scope = _provider.CreateScope( ); + IPermissionService svc = scope.ServiceProvider.GetRequiredService( ); + + // Viewer + Operator => Read + Execute + IReadOnlySet perms = await svc.GetPermissionsAsync( + ["Viewer", "Operator"], TestContext.CancellationToken ); + Assert.Contains( Permission.Read, perms ); + Assert.Contains( Permission.Execute, perms ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Identity/SecurityTests.cs b/src/Test/Werkr.Tests.Server/Identity/SecurityTests.cs new file mode 100644 index 0000000..1f8e14a --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Identity/SecurityTests.cs @@ -0,0 +1,165 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using Werkr.Common.Models; +using Werkr.Data.Identity; +using Werkr.Data.Identity.Entities; + +namespace Werkr.Tests.Server.Identity; + +/// +/// Security-focused tests for MFA reset/regen password requirements +/// and health endpoint timeout handling (§3.12.8). +/// +[TestClass] +public class SecurityTests { + [TestMethod] + public async Task MfaReset_RequiresPasswordConfirmation( ) { + _ = BuildServiceProvider( out UserManager um ); + + WerkrUser user = await CreateMfaEnabledUserAsync( um, "mfa-reset-nopass@local" ); + + // Simulate the password check that the Mfa.razor page performs + bool passwordValid = await um.CheckPasswordAsync( user, "wrong-password" ); + + Assert.IsFalse( passwordValid, + "MFA reset without valid password should be rejected." ); + + // The page only calls SetTwoFactorEnabledAsync(false) when password is valid. + // Verify 2FA is still enabled: + Assert.IsTrue( await um.GetTwoFactorEnabledAsync( user ), + "2FA should remain enabled when password check fails." ); + } + + [TestMethod] + public async Task MfaReset_ValidPassword_ResetsAuthenticator( ) { + _ = BuildServiceProvider( out UserManager um ); + + WerkrUser user = await CreateMfaEnabledUserAsync( um, "mfa-reset-valid@local" ); + + Assert.IsTrue( await um.GetTwoFactorEnabledAsync( user ) ); + + // Simulate valid password confirmation + bool passwordValid = await um.CheckPasswordAsync( user, "TestPassword123!" ); + Assert.IsTrue( passwordValid, "Correct password should pass check." ); + + // Perform the reset (matches Mfa.razor ResetMfaAsync logic) + IdentityResult disableResult = await um.SetTwoFactorEnabledAsync( user, false ); + Assert.IsTrue( disableResult.Succeeded ); + + _ = await um.ResetAuthenticatorKeyAsync( user ); + + Assert.IsFalse( await um.GetTwoFactorEnabledAsync( user ), + "2FA should be disabled after reset." ); + } + + [TestMethod] + public async Task RecoveryCodeRegen_RequiresPasswordConfirmation( ) { + _ = BuildServiceProvider( out UserManager um ); + + WerkrUser user = await CreateMfaEnabledUserAsync( um, "regen-nopass@local" ); + + // Generate initial codes + IEnumerable? initialCodes = + await um.GenerateNewTwoFactorRecoveryCodesAsync( user, 5 ); + Assert.IsNotNull( initialCodes ); + int initialCount = await um.CountRecoveryCodesAsync( user ); + + // Attempt regen with wrong password + bool passwordValid = await um.CheckPasswordAsync( user, "not-correct" ); + + Assert.IsFalse( passwordValid, + "Recovery code regen without valid password should be rejected." ); + + // Verify codes were NOT regenerated (count unchanged) + int afterCount = await um.CountRecoveryCodesAsync( user ); + Assert.AreEqual( initialCount, afterCount, + "Recovery code count should not change without valid password." ); + } + + [TestMethod] + public void HealthEndpoint_UnreachableAgent_ReturnsUnreachableStatus( ) { + // Simulates the BuildHealthAsync catch(RpcException) path + AgentHealthDto unreachable = new( + Guid.NewGuid( ), + "Offline-Agent", + "Unreachable", + null, + null, + null, + DateTime.UtcNow ); + + Assert.AreEqual( "Unreachable", unreachable.Status, + "RpcException should result in Unreachable status." ); + Assert.IsNull( unreachable.PowerShellAvailable, + "Unreachable agent should have null operator availability." ); + Assert.IsNull( unreachable.SystemShellAvailable, + "Unreachable agent should have null operator availability." ); + } + + [TestMethod] + public void HealthEndpoint_TotalTimeout_ReturnsPartialResults( ) { + // Simulates the catch(OperationCanceledException) path in the health endpoint. + // When the overall 10s timeout fires, completed tasks should be returned. + Task completedTask = Task.FromResult( new AgentHealthDto( + Guid.NewGuid( ), "Fast-Agent", "Connected", true, true, + DateTime.UtcNow, DateTime.UtcNow ) ); + + Task cancelledTask = Task.FromCanceled( + new CancellationToken( canceled: true ) ); + + List> tasks = [completedTask, cancelledTask]; + + List partial = [.. tasks + .Where( t => t.IsCompletedSuccessfully ) + .Select( t => t.Result )]; + + Assert.HasCount( 1, partial, + "Only successfully completed health checks should be returned." ); + Assert.AreEqual( "Fast-Agent", partial[0].ConnectionName ); + } + + // ── helpers ────────────────────────────────────────────────────────── + + private static async Task CreateMfaEnabledUserAsync( + UserManager um, string email ) { + WerkrUser user = new( ) { + UserName = email, + Email = email, + Name = email.Split( '@' )[0], + Enabled = true, + ChangePassword = false, + Requires2FA = false, + EmailConfirmed = true + }; + + IdentityResult createResult = await um.CreateAsync( user, "TestPassword123!" ); + Assert.IsTrue( createResult.Succeeded, + $"User creation failed: {string.Join( "; ", createResult.Errors.Select( e => e.Description ) )}" ); + + IdentityResult mfaResult = await um.SetTwoFactorEnabledAsync( user, true ); + Assert.IsTrue( mfaResult.Succeeded, "Enabling 2FA should succeed." ); + + return user; + } + + private static ServiceProvider BuildServiceProvider( out UserManager userManager ) { + ServiceCollection services = new( ); + string dbName = $"SecurityTests_{Guid.NewGuid( )}"; + + _ = services.AddDbContext( options => + options.UseInMemoryDatabase( dbName ) ); + + _ = services.AddIdentity( + Werkr.Data.Identity.Extensions.IdentityExtensions.ConfigureIdentityOptions ) + .AddEntityFrameworkStores( ) + .AddDefaultTokenProviders( ); + + _ = services.AddLogging( ); + + ServiceProvider provider = services.BuildServiceProvider( ); + userManager = provider.GetRequiredService>( ); + return provider; + } +} diff --git a/src/Test/Werkr.Tests.Server/Identity/UrlValidatorTests.cs b/src/Test/Werkr.Tests.Server/Identity/UrlValidatorTests.cs new file mode 100644 index 0000000..9b21f49 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Identity/UrlValidatorTests.cs @@ -0,0 +1,33 @@ +using Werkr.Server.Helpers; + +namespace Werkr.Tests.Server.Identity; + +[TestClass] +public class UrlValidatorTests { + [TestMethod] + public void ReturnUrl_LocalUrl_Accepted( ) { + bool result = UrlValidator.IsLocalUrl( "/agents" ); + + Assert.IsTrue( result ); + } + + [TestMethod] + public void ReturnUrl_AbsoluteUrl_Rejected( ) { + bool result = UrlValidator.IsLocalUrl( "https://evil.example.com" ); + + Assert.IsFalse( result ); + } + + [TestMethod] + public void ReturnUrl_ProtocolRelativeUrl_Rejected( ) { + bool result = UrlValidator.IsLocalUrl( "//evil.example.com" ); + + Assert.IsFalse( result ); + } + + [TestMethod] + public void ReturnUrl_NullOrEmpty_Rejected( ) { + Assert.IsFalse( UrlValidator.IsLocalUrl( null ) ); + Assert.IsFalse( UrlValidator.IsLocalUrl( string.Empty ) ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Identity/UserManagementTests.cs b/src/Test/Werkr.Tests.Server/Identity/UserManagementTests.cs new file mode 100644 index 0000000..9816661 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Identity/UserManagementTests.cs @@ -0,0 +1,263 @@ +using System.Security.Claims; + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using Werkr.Data.Identity; +using Werkr.Data.Identity.Entities; +using Werkr.Data.Identity.Roles; +using Werkr.Server.Identity; + +namespace Werkr.Tests.Server.Identity; + +/// +/// Tests for user management operations: CRUD, role management, +/// disable/enable, last-admin protection, and forced password reset (§3.12.3). +/// +[TestClass] +public class UserManagementTests { + public TestContext TestContext { get; set; } = null!; + + private ServiceProvider _provider = null!; + private UserManager _userManager = null!; + private RoleManager _roleManager = null!; + + [TestInitialize] + public void TestInit( ) { + ServiceCollection services = new( ); + string dbName = $"UserManagementTests_{Guid.NewGuid( )}"; + + _ = services.AddDbContext( options => + options.UseInMemoryDatabase( dbName ) ); + + _ = services.AddIdentity( + Werkr.Data.Identity.Extensions.IdentityExtensions.ConfigureIdentityOptions ) + .AddEntityFrameworkStores( ) + .AddDefaultTokenProviders( ); + + _ = services.AddLogging( ); + + _provider = services.BuildServiceProvider( ); + _userManager = _provider.GetRequiredService>( ); + _roleManager = _provider.GetRequiredService>( ); + + // Seed roles + foreach (string role in Enum.GetNames( )) { + _ = _roleManager.CreateAsync( new IdentityRole( role ) ).GetAwaiter( ).GetResult( ); + } + } + + [TestCleanup] + public void TestCleanup( ) { + _provider.Dispose( ); + } + + [TestMethod] + public async Task UserList_AdminCanSeeAllUsers( ) { + _ = await CreateUserAsync( "user1@local", "User 1" ); + _ = await CreateUserAsync( "user2@local", "User 2" ); + _ = await CreateUserAsync( "user3@local", "User 3" ); + + List users = await _userManager.Users.ToListAsync( TestContext.CancellationToken ); + + Assert.HasCount( 3, users, "Admin should see all users." ); + } + + [TestMethod] + public async Task CreateUser_ValidInput_CreatesUserWithRoles( ) { + WerkrUser newUser = new( ) { + UserName = "newuser@local", + Email = "newuser@local", + Name = "New User", + Enabled = true, + ChangePassword = true, + EmailConfirmed = true + }; + + IdentityResult createResult = await _userManager.CreateAsync( newUser, "StrongP@ssw0rd!!" ); + Assert.IsTrue( createResult.Succeeded, "User creation should succeed." ); + + IdentityResult roleResult = await _userManager.AddToRolesAsync( newUser, + [DefaultRoles.Operator.ToString( ), DefaultRoles.Viewer.ToString( )] ); + Assert.IsTrue( roleResult.Succeeded, "Role assignment should succeed." ); + + IList roles = await _userManager.GetRolesAsync( newUser ); + Assert.Contains( "Operator", roles ); + Assert.Contains( "Viewer", roles ); + } + + [TestMethod] + public async Task CreateUser_DuplicateEmail_Fails( ) { + _ = await CreateUserAsync( "dupe@local", "Original" ); + + WerkrUser dupe = new( ) { + UserName = "dupe@local", + Email = "dupe@local", + Name = "Duplicate", + EmailConfirmed = true + }; + + IdentityResult result = await _userManager.CreateAsync( dupe, "StrongP@ssw0rd!!" ); + + Assert.IsFalse( result.Succeeded, "Duplicate email should fail." ); + Assert.IsTrue( result.Errors.Any( ), + "Identity errors should indicate duplicate." ); + } + + [TestMethod] + public async Task CreateUser_AdminRole_AutoSetsRequires2FA( ) { + WerkrUser user = new( ) { + UserName = "adminmfa@local", + Email = "adminmfa@local", + Name = "Admin MFA", + Enabled = true, + ChangePassword = true, + Requires2FA = false, + EmailConfirmed = true + }; + + _ = await _userManager.CreateAsync( user, "StrongP@ssw0rd!!" ); + _ = await _userManager.AddToRoleAsync( user, DefaultRoles.Admin.ToString( ) ); + + // Simulate the auto-set logic from CreateUser page + bool isAdmin = await _userManager.IsInRoleAsync( user, DefaultRoles.Admin.ToString( ) ); + if (isAdmin && !user.Requires2FA) { + user.Requires2FA = true; + _ = await _userManager.UpdateAsync( user ); + } + + WerkrUser? reloaded = await _userManager.FindByEmailAsync( "adminmfa@local" ); + + Assert.IsTrue( reloaded!.Requires2FA, + "Admin role should auto-set Requires2FA = true." ); + } + + [TestMethod] + public async Task EditUser_UpdatesRoles( ) { + WerkrUser user = await CreateUserAsync( "roles@local", "Roles User" ); + _ = await _userManager.AddToRoleAsync( user, DefaultRoles.Viewer.ToString( ) ); + + IList oldRoles = await _userManager.GetRolesAsync( user ); + Assert.Contains( "Viewer", oldRoles ); + + _ = await _userManager.RemoveFromRoleAsync( user, DefaultRoles.Viewer.ToString( ) ); + _ = await _userManager.AddToRoleAsync( user, DefaultRoles.Operator.ToString( ) ); + + IList newRoles = await _userManager.GetRolesAsync( user ); + Assert.DoesNotContain( "Viewer", newRoles ); + Assert.Contains( "Operator", newRoles ); + } + + [TestMethod] + public async Task EditUser_CannotRemoveLastAdmin( ) { + WerkrUser admin = await CreateUserAsync( "soloadmin@local", "Solo Admin" ); + _ = await _userManager.AddToRoleAsync( admin, DefaultRoles.Admin.ToString( ) ); + + IList admins = await _userManager.GetUsersInRoleAsync( + DefaultRoles.Admin.ToString( ) ); + + // Simulate the last-admin protection check + bool wouldRemoveLastAdmin = admins.Count == 1 + && admins[0].Id == admin.Id; + + Assert.IsTrue( wouldRemoveLastAdmin, + "Removing the last admin should be detected and blocked." ); + } + + [TestMethod] + public async Task DisableUser_PreventsLogin( ) { + WerkrUser user = await CreateUserAsync( "disable-login@local", "Disabled Login" ); + user.Enabled = false; + _ = await _userManager.UpdateAsync( user ); + + WerkrUser? reloaded = await _userManager.FindByEmailAsync( "disable-login@local" ); + + Assert.IsFalse( reloaded!.Enabled, + "User should be disabled." ); + } + + [TestMethod] + public async Task DisableUser_InvalidatesSession( ) { + WerkrUser user = await CreateUserAsync( "disable-session@local", "Disabled Session" ); + user.Enabled = false; + _ = await _userManager.UpdateAsync( user ); + + CookieValidatePrincipalContext ctx = BuildContext( + _provider, BuildPrincipal( user.Id ), "/agents" ); + + WerkrCookieAuthEvents events = new( ); + await events.ValidatePrincipal( ctx ); + + Assert.IsNull( ctx.Principal, + "Disabled user's session should be invalidated." ); + } + + [TestMethod] + public async Task ForcePasswordReset_SetsFlag( ) { + WerkrUser user = await CreateUserAsync( "force-reset@local", "Force Reset" ); + + Assert.IsFalse( user.ChangePassword ); + + user.ChangePassword = true; + IdentityResult result = await _userManager.UpdateAsync( user ); + + Assert.IsTrue( result.Succeeded ); + + WerkrUser? reloaded = await _userManager.FindByEmailAsync( "force-reset@local" ); + Assert.IsTrue( reloaded!.ChangePassword, + "ChangePassword flag should be set by admin action." ); + } + + // ── helpers ────────────────────────────────────────────────────────── + + private async Task CreateUserAsync( string email, string name ) { + WerkrUser user = new( ) { + UserName = email, + Email = email, + Name = name, + Enabled = true, + ChangePassword = false, + Requires2FA = false, + EmailConfirmed = true + }; + + IdentityResult result = await _userManager.CreateAsync( user, "StrongP@ssw0rd!!" ); + Assert.IsTrue( result.Succeeded, + $"User creation failed: {string.Join( "; ", result.Errors.Select( e => e.Description ) )}" ); + return user; + } + + private static CookieValidatePrincipalContext BuildContext( + IServiceProvider serviceProvider, + ClaimsPrincipal principal, + string path ) { + DefaultHttpContext httpContext = new( ) { + RequestServices = serviceProvider + }; + + httpContext.Request.Path = path; + + AuthenticationScheme scheme = new( + CookieAuthenticationDefaults.AuthenticationScheme, + CookieAuthenticationDefaults.AuthenticationScheme, + typeof( CookieAuthenticationHandler ) ); + + CookieAuthenticationOptions options = new( ); + AuthenticationProperties properties = new( ); + AuthenticationTicket ticket = new( principal, properties, scheme.Name ); + + return new CookieValidatePrincipalContext( httpContext, scheme, options, ticket ); + } + + private static ClaimsPrincipal BuildPrincipal( string userId ) { + ClaimsIdentity identity = new( + [new Claim( ClaimTypes.NameIdentifier, userId )], + CookieAuthenticationDefaults.AuthenticationScheme ); + + return new ClaimsPrincipal( identity ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Identity/WerkrCookieAuthEventsTests.cs b/src/Test/Werkr.Tests.Server/Identity/WerkrCookieAuthEventsTests.cs new file mode 100644 index 0000000..9279cbd --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Identity/WerkrCookieAuthEventsTests.cs @@ -0,0 +1,208 @@ +using System.Security.Claims; + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using Werkr.Data.Identity; +using Werkr.Data.Identity.Entities; +using Werkr.Server.Identity; + +namespace Werkr.Tests.Server.Identity; + +[TestClass] +public class WerkrCookieAuthEventsTests { + [TestMethod] + public async Task ValidatePrincipal_UserDeleted_RejectsPrincipal( ) { + ServiceProvider serviceProvider = BuildIdentityServiceProvider( out _ ); + + ClaimsPrincipal principal = BuildPrincipal( "missing-user" ); + CookieValidatePrincipalContext context = BuildContext( serviceProvider, principal, "/" ); + + WerkrCookieAuthEvents events = new( ); + await events.ValidatePrincipal( context ); + + Assert.IsNull( context.Principal ); + } + + [TestMethod] + public async Task ValidatePrincipal_UserDisabled_RejectsPrincipal( ) { + ServiceProvider serviceProvider = BuildIdentityServiceProvider( out UserManager userManager ); + + WerkrUser user = new( ) { + UserName = "disabled@local", + Email = "disabled@local", + Name = "Disabled", + Enabled = false, + ChangePassword = false, + Requires2FA = false, + EmailConfirmed = true + }; + + _ = await userManager.CreateAsync( user, "TestPassword123!" ); + + ClaimsPrincipal principal = BuildPrincipal( user.Id ); + CookieValidatePrincipalContext context = BuildContext( serviceProvider, principal, "/" ); + + WerkrCookieAuthEvents events = new( ); + await events.ValidatePrincipal( context ); + + Assert.IsNull( context.Principal ); + } + + [TestMethod] + public async Task ValidatePrincipal_ChangePasswordTrue_RedirectsToChangePassword( ) { + ServiceProvider serviceProvider = BuildIdentityServiceProvider( out UserManager userManager ); + + WerkrUser user = new( ) { + UserName = "user@local", + Email = "user@local", + Name = "User", + Enabled = true, + ChangePassword = true, + Requires2FA = false, + EmailConfirmed = true + }; + + _ = await userManager.CreateAsync( user, "TestPassword123!" ); + + ClaimsPrincipal principal = BuildPrincipal( user.Id ); + CookieValidatePrincipalContext context = BuildContext( serviceProvider, principal, "/agents" ); + + WerkrCookieAuthEvents events = new( ); + await events.ValidatePrincipal( context ); + + Assert.AreEqual( "/account/change-password", context.HttpContext.Response.Headers.Location.ToString( ) ); + Assert.IsTrue( context.ShouldRenew ); + } + + [TestMethod] + public async Task ValidatePrincipal_ChangePasswordPath_DoesNotRedirect( ) { + ServiceProvider serviceProvider = BuildIdentityServiceProvider( out UserManager userManager ); + + WerkrUser user = new( ) { + UserName = "user2@local", + Email = "user2@local", + Name = "User2", + Enabled = true, + ChangePassword = true, + Requires2FA = false, + EmailConfirmed = true + }; + + _ = await userManager.CreateAsync( user, "TestPassword123!" ); + + ClaimsPrincipal principal = BuildPrincipal( user.Id ); + CookieValidatePrincipalContext context = BuildContext( serviceProvider, principal, "/account/change-password" ); + + WerkrCookieAuthEvents events = new( ); + await events.ValidatePrincipal( context ); + + Assert.AreEqual( string.Empty, context.HttpContext.Response.Headers.Location.ToString( ) ); + } + + [TestMethod] + public async Task ValidatePrincipal_Requires2FaNotEnrolled_RedirectsToMfa( ) { + ServiceProvider serviceProvider = BuildIdentityServiceProvider( out UserManager userManager ); + + WerkrUser user = new( ) { + UserName = "admin@local", + Email = "admin@local", + Name = "Admin", + Enabled = true, + ChangePassword = false, + Requires2FA = true, + TwoFactorEnabled = false, + EmailConfirmed = true + }; + + _ = await userManager.CreateAsync( user, "TestPassword123!" ); + + ClaimsPrincipal principal = BuildPrincipal( user.Id ); + CookieValidatePrincipalContext context = BuildContext( serviceProvider, principal, "/" ); + + WerkrCookieAuthEvents events = new( ); + await events.ValidatePrincipal( context ); + + Assert.AreEqual( "/account/manage/mfa?required=true", context.HttpContext.Response.Headers.Location.ToString( ) ); + Assert.IsTrue( context.ShouldRenew ); + } + + [TestMethod] + public async Task ValidatePrincipal_AllFlagsOk_NoAction( ) { + ServiceProvider serviceProvider = BuildIdentityServiceProvider( out UserManager userManager ); + + WerkrUser user = new( ) { + UserName = "viewer@local", + Email = "viewer@local", + Name = "Viewer", + Enabled = true, + ChangePassword = false, + Requires2FA = false, + TwoFactorEnabled = false, + EmailConfirmed = true + }; + + _ = await userManager.CreateAsync( user, "TestPassword123!" ); + + ClaimsPrincipal principal = BuildPrincipal( user.Id ); + CookieValidatePrincipalContext context = BuildContext( serviceProvider, principal, "/" ); + + WerkrCookieAuthEvents events = new( ); + await events.ValidatePrincipal( context ); + + Assert.IsNotNull( context.Principal ); + Assert.AreEqual( string.Empty, context.HttpContext.Response.Headers.Location.ToString( ) ); + } + + private static ServiceProvider BuildIdentityServiceProvider( out UserManager userManager ) { + ServiceCollection services = new( ); + string dbName = $"CookieEventsTests_{Guid.NewGuid( )}"; + + _ = services.AddDbContext( options => + options.UseInMemoryDatabase( dbName ) ); + + _ = services.AddIdentity( ) + .AddEntityFrameworkStores( ) + .AddDefaultTokenProviders( ); + + _ = services.AddLogging( ); + + ServiceProvider provider = services.BuildServiceProvider( ); + userManager = provider.GetRequiredService>( ); + return provider; + } + + private static CookieValidatePrincipalContext BuildContext( + IServiceProvider serviceProvider, + ClaimsPrincipal principal, + string path ) { + DefaultHttpContext httpContext = new( ) { + RequestServices = serviceProvider + }; + + httpContext.Request.Path = path; + + AuthenticationScheme scheme = new( + CookieAuthenticationDefaults.AuthenticationScheme, + CookieAuthenticationDefaults.AuthenticationScheme, + typeof( CookieAuthenticationHandler ) ); + + CookieAuthenticationOptions options = new( ); + AuthenticationProperties properties = new( ); + AuthenticationTicket ticket = new( principal, properties, scheme.Name ); + + return new CookieValidatePrincipalContext( httpContext, scheme, options, ticket ); + } + + private static ClaimsPrincipal BuildPrincipal( string userId ) { + ClaimsIdentity identity = new( + [new Claim( ClaimTypes.NameIdentifier, userId )], + CookieAuthenticationDefaults.AuthenticationScheme ); + + return new ClaimsPrincipal( identity ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Pages/AgentDetailTests.cs b/src/Test/Werkr.Tests.Server/Pages/AgentDetailTests.cs new file mode 100644 index 0000000..72bd5fc --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Pages/AgentDetailTests.cs @@ -0,0 +1,97 @@ +using Werkr.Common.Models; +using Werkr.Core.Cryptography; + +namespace Werkr.Tests.Server.Pages; + +/// +/// Tests for Agent Detail page data: connection info display, name editing, +/// and revoke status changes (§3.12.5). +/// +[TestClass] +public class AgentDetailTests { + [TestMethod] + public void AgentDetail_ShowsConnectionInfo( ) { + Guid id = Guid.NewGuid( ); + DateTime registered = new( 2026, 2, 20, 10, 0, 0, DateTimeKind.Utc ); + DateTime lastSeen = new( 2026, 2, 21, 8, 30, 0, DateTimeKind.Utc ); + + AgentDetailDto dto = new( + id, + "Production-Agent", + "https://agent.example.com:5001", + "Connected", + "abcdef1234567890", + registered, + lastSeen, + PowerShellAvailable: true, + SystemShellAvailable: true ); + + Assert.AreEqual( id, dto.Id ); + Assert.AreEqual( "Production-Agent", dto.ConnectionName ); + Assert.AreEqual( "https://agent.example.com:5001", dto.RemoteUrl ); + Assert.AreEqual( "Connected", dto.Status ); + Assert.AreEqual( "abcdef1234567890", dto.RsaKeyFingerprint ); + Assert.AreEqual( registered, dto.RegisteredAt ); + Assert.AreEqual( lastSeen, dto.LastSeen ); + Assert.IsTrue( dto.PowerShellAvailable!.Value, "PowerShell should be available." ); + Assert.IsTrue( dto.SystemShellAvailable!.Value, "SystemShell should be available." ); + } + + [TestMethod] + public void AgentDetail_EditName_ProducesValidRequest( ) { + string newName = " Renamed-Agent "; + string trimmed = newName.Trim( ); + + UpdateAgentRequest request = new( trimmed, null ); + + Assert.AreEqual( "Renamed-Agent", request.ConnectionName ); + Assert.IsLessThanOrEqualTo( 200, request.ConnectionName!.Length, + "Connection name must be 200 characters or fewer." ); + } + + [TestMethod] + public void AgentDetail_EditName_RejectsEmptyName( ) { + string newName = " "; + bool isValid = !string.IsNullOrWhiteSpace( newName ); + + Assert.IsFalse( isValid, "Empty/whitespace name should be rejected." ); + } + + [TestMethod] + public void AgentDetail_EditName_RejectsOverlongName( ) { + string newName = new( 'x', 201 ); + string trimmed = newName.Trim( ); + bool isValid = !string.IsNullOrWhiteSpace( trimmed ) && trimmed.Length <= 200; + + Assert.IsFalse( isValid, "Name over 200 characters should be rejected." ); + } + + [TestMethod] + public void AgentDetail_Revoke_ChangesStatus( ) { + // Simulate the revoke flow: connected → revoked + AgentListDto before = new( + Guid.NewGuid( ), "Agent-R", "https://a:5001", "Connected", DateTime.UtcNow, DateTime.UtcNow ); + + Assert.AreEqual( "Connected", before.Status ); + + // After revocation, the API returns a new DTO with Revoked status + AgentListDto after = before with { Status = "Revoked" }; + + Assert.AreEqual( "Revoked", after.Status ); + } + + [TestMethod] + public void AgentDetail_RsaFingerprint_ComputesConsistently( ) { + string publicKey = "testModulusAQAB"; + + string fingerprint1 = EncryptionProvider.ComputeKeyFingerprint( publicKey ); + string fingerprint2 = EncryptionProvider.ComputeKeyFingerprint( publicKey ); + + Assert.AreEqual( fingerprint1, fingerprint2, + "Same public key should produce the same fingerprint." ); + Assert.IsFalse( string.IsNullOrWhiteSpace( fingerprint1 ), + "Fingerprint should not be empty." ); + Assert.AreEqual( 64, fingerprint1.Length, + "SHA-256 fingerprint should be 64 hex characters." ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Pages/DashboardTests.cs b/src/Test/Werkr.Tests.Server/Pages/DashboardTests.cs new file mode 100644 index 0000000..adce822 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Pages/DashboardTests.cs @@ -0,0 +1,148 @@ +using Werkr.Common.Models; + +namespace Werkr.Tests.Server.Pages; + +/// +/// Tests for the Dashboard (Home.razor) functionality — agent count, status breakdown, +/// system info, quick actions visibility, and activity feed (§3.12.4). +/// +[TestClass] +public class DashboardTests { + [TestMethod] + public void Dashboard_AgentListDto_CountsCorrectly( ) { + List agents = [ + new( Guid.NewGuid( ), "Agent-1", "https://a1:5001", "Connected", DateTime.UtcNow, DateTime.UtcNow ), + new( Guid.NewGuid( ), "Agent-2", "https://a2:5001", "Disconnected", null, DateTime.UtcNow ), + new( Guid.NewGuid( ), "Agent-3", "https://a3:5001", "Connected", DateTime.UtcNow, DateTime.UtcNow ), + ]; + + Assert.HasCount( 3, agents, "Dashboard should show total agent count." ); + } + + [TestMethod] + public void Dashboard_StatusBreakdown_CategorisesCorrectly( ) { + List agents = [ + new( Guid.NewGuid( ), "Agent-1", "https://a1:5001", "Connected", DateTime.UtcNow, DateTime.UtcNow ), + new( Guid.NewGuid( ), "Agent-2", "https://a2:5001", "Connected", DateTime.UtcNow, DateTime.UtcNow ), + new( Guid.NewGuid( ), "Agent-3", "https://a3:5001", "Disconnected", null, DateTime.UtcNow ), + new( Guid.NewGuid( ), "Agent-4", "https://a4:5001", "Revoked", null, DateTime.UtcNow ), + ]; + + int connected = agents.Count( a => + string.Equals( a.Status, "Connected", StringComparison.OrdinalIgnoreCase ) ); + int disconnected = agents.Count( a => + string.Equals( a.Status, "Disconnected", StringComparison.OrdinalIgnoreCase ) ); + int revoked = agents.Count( a => + string.Equals( a.Status, "Revoked", StringComparison.OrdinalIgnoreCase ) ); + + Assert.AreEqual( 2, connected, "Connected count mismatch." ); + Assert.AreEqual( 1, disconnected, "Disconnected count mismatch." ); + Assert.AreEqual( 1, revoked, "Revoked count mismatch." ); + } + + [TestMethod] + public void Dashboard_SystemInfo_ComputesUptime( ) { + DateTime startTime = DateTime.UtcNow.AddHours( -2 ).AddMinutes( -15 ); + + TimeSpan uptime = DateTime.UtcNow - startTime; + string formatted = uptime.TotalHours >= 1 + ? $"{uptime.Hours}h {uptime.Minutes}m" + : $"{Math.Max( 0, uptime.Minutes )}m"; + + Assert.Contains( "h", formatted, "Uptime should show hours." ); + Assert.IsGreaterThan( 120, uptime.TotalMinutes, "Uptime should be > 120 minutes." ); + } + + [TestMethod] + public void Dashboard_QuickActions_AdminSeesAll( ) { + // Simulate the role-checking logic used by AuthorizeView + List adminRoles = ["Admin"]; + + bool canRegister = adminRoles.Contains( "Admin" ); + bool canSeeConsole = adminRoles.Contains( "Admin" ) || adminRoles.Contains( "Operator" ); + bool canManageUsers = adminRoles.Contains( "Admin" ); + bool canViewSettings = adminRoles.Contains( "Admin" ); + + Assert.IsTrue( canRegister, "Admin should see Register Agent." ); + Assert.IsTrue( canSeeConsole, "Admin should see Operator Console." ); + Assert.IsTrue( canManageUsers, "Admin should see Manage Users." ); + Assert.IsTrue( canViewSettings, "Admin should see View Settings." ); + } + + [TestMethod] + public void Dashboard_QuickActions_OperatorSeesSubset( ) { + List operatorRoles = ["Operator"]; + + bool canRegister = operatorRoles.Contains( "Admin" ); + bool canSeeConsole = operatorRoles.Contains( "Admin" ) || operatorRoles.Contains( "Operator" ); + bool canManageUsers = operatorRoles.Contains( "Admin" ); + bool canViewSettings = operatorRoles.Contains( "Admin" ); + + Assert.IsFalse( canRegister, "Operator should NOT see Register Agent." ); + Assert.IsTrue( canSeeConsole, "Operator should see Operator Console." ); + Assert.IsFalse( canManageUsers, "Operator should NOT see Manage Users." ); + Assert.IsFalse( canViewSettings, "Operator should NOT see View Settings." ); + } + + [TestMethod] + public void Dashboard_ActivityFeed_SortsByDescendingTime( ) { + Guid agentId = Guid.NewGuid( ); + DateTime now = DateTime.UtcNow; + + List events = [ + new( agentId, "Agent-1", "Registered", now.AddHours( -3 ), "Connected" ), + new( agentId, "Agent-1", "Last Seen", now.AddMinutes( -5 ), "Connected" ), + new( agentId, "Agent-1", "Revoked", now.AddMinutes( -1 ), "Revoked" ), + ]; + + List sorted = [.. events + .OrderByDescending( e => e.OccurredAtUtc )]; + + Assert.AreEqual( "Revoked", sorted[0].EventType, + "Most recent event should be first." ); + Assert.AreEqual( "Registered", sorted[^1].EventType, + "Oldest event should be last." ); + } + + [TestMethod] + public void Dashboard_HealthDto_UnreachableAgent_HasNullAvailability( ) { + AgentHealthDto unreachable = new( + Guid.NewGuid( ), + "Unreachable-Agent", + "Unreachable", + null, + null, + null, + DateTime.UtcNow ); + + Assert.AreEqual( "Unreachable", unreachable.Status ); + Assert.IsNull( unreachable.PowerShellAvailable, + "Unreachable agent should have null PowerShell availability." ); + Assert.IsNull( unreachable.SystemShellAvailable, + "Unreachable agent should have null SystemShell availability." ); + } + + [TestMethod] + public void Dashboard_DatabaseHealth_AllConnected_ReportsHealthy( ) { + List diagnostics = [ + new( "Application", "Npgsql", true, 3, 0, [] ), + new( "Identity", "Npgsql", true, 1, 0, [] ), + ]; + + bool healthy = diagnostics.Count > 0 && diagnostics.All( d => d.IsConnected ); + + Assert.IsTrue( healthy, "All databases connected should report healthy." ); + } + + [TestMethod] + public void Dashboard_DatabaseHealth_OneDisconnected_ReportsUnhealthy( ) { + List diagnostics = [ + new( "Application", "Npgsql", true, 3, 0, [] ), + new( "Identity", "Npgsql", false, 1, 0, [] ), + ]; + + bool healthy = diagnostics.Count > 0 && diagnostics.All( d => d.IsConnected ); + + Assert.IsFalse( healthy, "One disconnected database should report unhealthy." ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Pages/DtoModelTests.cs b/src/Test/Werkr.Tests.Server/Pages/DtoModelTests.cs new file mode 100644 index 0000000..e914093 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Pages/DtoModelTests.cs @@ -0,0 +1,106 @@ +using Werkr.Common.Models; + +namespace Werkr.Tests.Server.Pages; + +/// +/// Unit tests for DTO construction and helper logic used by Blazor pages. +/// +[TestClass] +public class DtoModelTests { + [TestMethod] + public void ScheduleDto_RoundTrip( ) { + ScheduleDto dto = new( + Guid.NewGuid( ), + "Test Schedule", + 60, + new StartDateTimeDto( new DateOnly( 2026, 1, 1 ), new TimeOnly( 8, 0 ), "UTC" ), + null, + new DailyRecurrenceDto( 1 ), + null, + null, + null ); + + Assert.AreEqual( "Test Schedule", dto.Name ); + Assert.AreEqual( 60, dto.StopTaskAfterMinutes ); + Assert.IsNotNull( dto.DailyRecurrence ); + Assert.AreEqual( 1, dto.DailyRecurrence.DayInterval ); + Assert.IsNull( dto.Expiration ); + } + + [TestMethod] + public void TaskDto_TagsPreserved( ) { + TaskDto dto = new( + 42, + "Backup DB", + "Run nightly backup", + "PowerShell", + "Backup-Database", + [], + ["prod", "db"], + true, + null, + 5, + null, + "ExitCode", + null, + null ); + + Assert.AreEqual( 42, dto.Id ); + Assert.HasCount( 2, dto.TargetTags ); + Assert.Contains( "prod", dto.TargetTags ); + Assert.Contains( "db", dto.TargetTags ); + } + + [TestMethod] + public void WorkflowStepDto_Dependencies( ) { + StepDependencyDto dep = new( 2, 1 ); + WorkflowStepDto step = new( + 2, 1, 100, 2, "Always", null, 0, null, "AllSucceeded", [dep] ); + + Assert.HasCount( 1, step.Dependencies ); + Assert.AreEqual( 1, step.Dependencies[0].DependsOnStepId ); + Assert.AreEqual( 2, step.Dependencies[0].StepId ); + } + + [TestMethod] + public void WeeklyRecurrenceDto_FlagIntValues( ) { + // Sun=1, Mon=2, Wed=8 => 11 + WeeklyRecurrenceDto dto = new( 1, 11 ); + Assert.AreEqual( 1, dto.WeekInterval ); + Assert.AreEqual( 11, dto.DaysOfWeek ); + } + + [TestMethod] + public void MonthlyRecurrenceDto_FlagIntValues( ) { + // Jan=1, Mar=4 => 5 + MonthlyRecurrenceDto dto = new( [15], 5, null, null ); + Assert.AreEqual( 5, dto.MonthsOfYear ); + Assert.IsNull( dto.WeekNumber ); + Assert.IsNull( dto.DaysOfWeek ); + Assert.AreEqual( 15, dto.DayNumbers![0] ); + } + + [TestMethod] + public void OccurrencePreviewResponse_EmptyList( ) { + OccurrencePreviewResponse resp = new( + Guid.NewGuid( ), + DateTime.UtcNow.AddDays( 30 ), + [] ); + + Assert.IsEmpty( resp.Occurrences ); + } + + [TestMethod] + public void DagValidationResult_Valid( ) { + DagValidationResult result = new( true, [] ); + Assert.IsTrue( result.IsValid ); + Assert.IsEmpty( result.Errors ); + } + + [TestMethod] + public void DagValidationResult_Invalid( ) { + DagValidationResult result = new( false, ["Cycle detected at step 3"] ); + Assert.IsFalse( result.IsValid ); + Assert.HasCount( 1, result.Errors ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Pages/PageDiscoveryTests.cs b/src/Test/Werkr.Tests.Server/Pages/PageDiscoveryTests.cs new file mode 100644 index 0000000..80c3013 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Pages/PageDiscoveryTests.cs @@ -0,0 +1,67 @@ +using System.Reflection; + +using Microsoft.AspNetCore.Components; + +namespace Werkr.Tests.Server.Pages; + +/// +/// Verifies all Blazor pages are present and discoverable via reflection. +/// +[TestClass] +public class PageDiscoveryTests { + private static readonly string[] s_expectedRoutes = [ + "/calendar", + "/tasklist", + "/jobs", + "/jobs/{Id:guid}", + "/schedules", + "/schedules/create", + "/schedules/{Id:guid}", + "/tasks", + "/tasks/create", + "/tasks/{Id:long}", + "/workflows", + "/workflows/create", + "/workflows/{Id:long}", + "/workflows/{WorkflowId:long}/runs", + "/workflows/runs/{RunId:guid}", + ]; + + [TestMethod] + public void AllExpectedPages_AreDiscoverable( ) { + Assembly serverAssembly = typeof( Werkr.Server.Identity.WerkrCookieAuthEvents ).Assembly; + + HashSet discoveredRoutes = [.. serverAssembly + .GetTypes( ) + .Where( t => typeof( ComponentBase ).IsAssignableFrom( t ) ) + .SelectMany( t => t.GetCustomAttributes( ) ) + .Select( r => r.Template )]; + + List missing = []; + foreach (string route in s_expectedRoutes) { + if (!discoveredRoutes.Contains( route )) { + missing.Add( route ); + } + } + + if (missing.Count > 0) { + Assert.Fail( $"Missing expected page routes:\n{string.Join( "\n", missing )}" ); + } + } + + [TestMethod] + public void AllExpectedPages_HaveInteractiveServerRenderMode( ) { + Assembly serverAssembly = typeof( Werkr.Server.Identity.WerkrCookieAuthEvents ).Assembly; + + List pageTypes = [.. serverAssembly + .GetTypes( ) + .Where( t => typeof( ComponentBase ).IsAssignableFrom( t ) ) + .Where( t => t.GetCustomAttributes( ) + .Any( r => s_expectedRoutes.Contains( r.Template ) ) )]; + + Assert.IsNotEmpty( pageTypes, "Should discover expected page types." ); + + // Each page should either have StreamRendering attribute or RenderModeAttribute + // A Blazor page with @rendermode InteractiveServer gets the attribute applied + } +} diff --git a/src/Test/Werkr.Tests.Server/Pages/SettingsTests.cs b/src/Test/Werkr.Tests.Server/Pages/SettingsTests.cs new file mode 100644 index 0000000..d5f514e --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Pages/SettingsTests.cs @@ -0,0 +1,59 @@ +using Werkr.Common.Models; + +namespace Werkr.Tests.Server.Pages; + +/// +/// Tests for the Settings/Diagnostics page: configuration display, +/// database health, and access control (§3.12.6). +/// +[TestClass] +public class SettingsTests { + [TestMethod] + public void Settings_DatabaseHealthDto_DisplaysCorrectly( ) { + DatabaseHealthDto dto = new( + "Application", + "Npgsql.EntityFrameworkCore.PostgreSQL", + IsConnected: true, + AppliedMigrationCount: 3, + PendingMigrationCount: 0, + [] ); + + Assert.AreEqual( "Application", dto.ContextName ); + Assert.IsTrue( dto.IsConnected ); + Assert.AreEqual( 3, dto.AppliedMigrationCount ); + Assert.AreEqual( 0, dto.PendingMigrationCount ); + Assert.HasCount( 0, dto.PendingMigrations ); + } + + [TestMethod] + public void Settings_DatabaseHealthDto_ShowsPendingMigrations( ) { + DatabaseHealthDto dto = new( + "Identity", + "Npgsql.EntityFrameworkCore.PostgreSQL", + IsConnected: true, + AppliedMigrationCount: 1, + PendingMigrationCount: 2, + ["20260220_AddLastLoginUtc", "20260221_AddUserPrefs"] ); + + Assert.AreEqual( 2, dto.PendingMigrationCount ); + Assert.HasCount( 2, dto.PendingMigrations ); + Assert.Contains( "20260220_AddLastLoginUtc", dto.PendingMigrations ); + } + + [TestMethod] + public void Settings_AgentHealthSummary_AggregatesCorrectly( ) { + List health = [ + new( Guid.NewGuid( ), "Agent-1", "Connected", true, true, DateTime.UtcNow, DateTime.UtcNow ), + new( Guid.NewGuid( ), "Agent-2", "Connected", true, false, DateTime.UtcNow, DateTime.UtcNow ), + new( Guid.NewGuid( ), "Agent-3", "Unreachable", null, null, null, DateTime.UtcNow ), + ]; + + int withPowerShell = health.Count( h => h.PowerShellAvailable == true ); + int withSystemShell = health.Count( h => h.SystemShellAvailable == true ); + int unreachable = health.Count( h => h.Status == "Unreachable" ); + + Assert.AreEqual( 2, withPowerShell, "Two agents should have PowerShell." ); + Assert.AreEqual( 1, withSystemShell, "One agent should have SystemShell." ); + Assert.AreEqual( 1, unreachable, "One agent should be unreachable." ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Placeholder.cs b/src/Test/Werkr.Tests.Server/Placeholder.cs new file mode 100644 index 0000000..0d0f42d --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Placeholder.cs @@ -0,0 +1,12 @@ +using System.Reflection; + +namespace Werkr.Tests.Server; + +[TestClass] +public class Placeholder { + [TestMethod] + public void ServerProjectCompiles( ) { + Assembly result = typeof( Werkr.Common.Configuration.WerkrConfiguration ).Assembly; + Assert.IsNotNull( result ); + } +} diff --git a/src/Test/Werkr.Tests.Server/Werkr.Tests.Server.csproj b/src/Test/Werkr.Tests.Server/Werkr.Tests.Server.csproj new file mode 100644 index 0000000..b73a8de --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Werkr.Tests.Server.csproj @@ -0,0 +1,24 @@ + + + + Exe + false + true + true + false + + + + + + + + + + + + + + + + diff --git a/src/Test/Werkr.Tests.Server/packages.lock.json b/src/Test/Werkr.Tests.Server/packages.lock.json new file mode 100644 index 0000000..44aa078 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/packages.lock.json @@ -0,0 +1,1028 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.EntityFrameworkCore.InMemory": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "Gw8wef/BAHx59FiFJzgVHpccq/Qwtrghb3NzofgHZwhKxkmc9ePB7bqVhMZzrceQ4wTEwfPwD8y61FaOFPIf7Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Direct", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "MSTest": { + "type": "Direct", + "requested": "[4.1.0, )", + "resolved": "4.1.0", + "contentHash": "2bk47yg7HcHRyf6Zf0XgCZicTVTQj4D5lonYTO7lWMxCQB+x66VrQNc2dADBfzthKXfHaA37m8i+VV5h6SbWiA==", + "dependencies": { + "MSTest.TestAdapter": "4.1.0", + "MSTest.TestFramework": "4.1.0", + "Microsoft.NET.Test.Sdk": "18.0.1", + "Microsoft.Testing.Extensions.CodeCoverage": "18.4.1", + "Microsoft.Testing.Extensions.TrxReport": "2.1.0" + } + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==", + "dependencies": { + "Grpc.Core.Api": "2.76.0" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "STkCfgCECt2cAekgBpXxFDefH5wd4ytYZKihIZSmQqY92BP8N9qN71qFyRpry8Sl/qT5A+bpwe8v7sjDtg5LEA==" + }, + "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c8GgMKpnNf8fUOKXaZXKV5XaLSlvAts8ICvcPr5CIfjHEWJtbq+URIfBGYesyhnOlWAiSgVsdCBZxMEJIHgfLw==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.3" + } + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA==" + }, + "Microsoft.DiaSymReader": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "QcZrCETsBJqy/vQpFtJc+jSXQ0K5sucQ6NUFbTNVHD4vfZZOwjZ/3sBzczkC4DityhD3AVO/+K/+9ioLs1AgRA==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Features": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "djFt1Jt+2uREWWVQiiA4ilYBDtHHY7nK08c5K8xBD9+XFNw3KDVprylrMkH08bZGK3ZHRAkS7JDV9srfLrcm/g==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Embedded": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "kw/xPl7m4Gv6bqx2ojihTtWiN2K2AklyMIrvncuSi2MOdwu0oMKoyh0G3p2Brt7m43Q9ER0IaA2G4EGjfgDh/w==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "M5gWob3dtzlF14oto1lR1ZuSJrR0gGc+obv7zY9LGmX5y3Ndpve29MrrjqJW/m4CFud4TE/KFUuHjjtwxhCO8g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.Telemetry": "10.3.0" + } + }, + "Microsoft.Extensions.Identity.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GdhTmz+BiVEdsFCT7Vqjhlx8q7j7kGPLinJjudPLO48DxZjSIwh9KlOd/AYJoGR21NjkkHiWijcB3RG7rIfMqw==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Identity.Stores": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "XWu+Xg0dc0VKJxW7iTuhpnSD2jqZ4Kcdr7f3vUf7LOmPkawBLGkUuUA3rl+QQCbXAGnomV/I9T2wTxe1BKkVEA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.Identity.Core": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PBlaoYeusaxNYyN4WFjzcXWlUDSvLUPxy/e6oP1SONOOYA/oBWT2uBmFGJMV9VTtXiXXxCB39LqlYWbsWE4UKA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "dQKlVXzqflsv5X8iDlAN5YmTL1GcLCrOLKo1s9PNdfjqxeu0S/jmWTfiLGno+8+o1qFL3+VFAH5/ftmypN+sPw==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.ServiceDiscovery.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Features": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==", + "dependencies": { + "Microsoft.CodeCoverage": "18.0.1", + "Microsoft.TestPlatform.TestHost": "18.0.1" + } + }, + "Microsoft.Testing.Extensions.CodeCoverage": { + "type": "Transitive", + "resolved": "18.4.1", + "contentHash": "l1VZM9dg9s76L5D288ipAT4HRYDJ6Vxh8wX20gfS9VnpueedRfN4/aGNn4oA1g6pwq2WSM3Ci7IoSSGPiqu+WQ==", + "dependencies": { + "Microsoft.DiaSymReader": "2.0.0", + "Microsoft.Extensions.DependencyModel": "8.0.2", + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "5TwgTx2u7k9Al/xbZ18QXq4Hdy2xewkVTI6K3sk+jY2ykqUkIKNuj7rFu3GOV5KnEUkevhw6eZcyZs77STHJIA==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "cXmP225WcMLLOSrW8xekaNhfzdBwXX3cbXbE5qSzmLbK0KZe3z8rAObKj70FWiPPPzm2W22x0ZW93gsmAfK6Mg==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.1.0", + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "D8xmIJYQFJ6D49Rx5/vPrkZZxb338Jkew+eSqZLBfBiWKw4QZKy3i1BOXiLfz0lOmaNErwDz/YWRojCdNl+B9Q==", + "dependencies": { + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Extensions.VSTestBridge": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "bNRIEA2YoGr+Y+7LHdA7i1U80+7BAdf4K4Qh4Kx6eKkoBK/NV7QpoMg+GWPP0/eqAFzuUmUOIPVZ87Oo0Vyxmw==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Microsoft.Testing.Extensions.Telemetry": "2.1.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.1.0", + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "aHkjNTGIA+Zbdw6RJgSFrbDrCjO0CgqpElqYcvkRSeUhBv2bKarnvU3ep786U7UqrPlArT/B7VmImRibJD0Zrg==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "UpfPebXQtHGrWz21+YLHmJSm+5zsuPE9U9pfdCtoB+67g75fDmWlNgpkH2ZmdVhSwkjNIed9Icg8Iu63z2ei5Q==", + "dependencies": { + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A==" + }, + "MSTest.Analyzers": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "4ElL/aqomiUInr090VN4udqz46AuszXLrifHkLrgj0zb7na8eAoyUQt3BwDLTcGd1bSkmk3SfD02rZtKU+ZiqQ==" + }, + "MSTest.TestAdapter": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "bRW1Hftwq0XbcVExcAbj4YAfSZDRAziL0mygDkPBvaUe2nSsWFQIatze5lHVjPFJMvSFgWnItku4pguIy5FowQ==", + "dependencies": { + "MSTest.TestFramework": "4.1.0", + "Microsoft.Testing.Extensions.VSTestBridge": "2.1.0", + "Microsoft.Testing.Platform.MSBuild": "2.1.0" + } + }, + "MSTest.TestFramework": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "BzpvsK+CRbk6khwY62h+7HfYzIxtJXyPv9tOI9T90cy5CVy+WI1JkN4ZaNL4Dobqb6dywSwabLTIbPZKpdrr+A==", + "dependencies": { + "MSTest.Analyzers": "4.1.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "OpenTelemetry.Api": "1.15.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "6.0.0" + } + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.AspNetCore.Authorization": "[10.0.3, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.core": { + "type": "Project", + "dependencies": { + "Grpc.Net.Client": "[2.76.0, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.3, )", + "System.Security.Cryptography.ProtectedData": "[10.0.3, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )" + } + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "werkr.data.identity": { + "type": "Project", + "dependencies": { + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.AspNetCore.Identity.UI": "[10.0.3, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )" + } + }, + "werkr.server": { + "type": "Project", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "[8.16.0, )", + "QRCoder": "[1.7.0, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.File": "[7.0.0, )", + "Serilog.Sinks.OpenTelemetry": "[4.2.0, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data.Identity": "[1.0.0, )", + "Werkr.ServiceDefaults": "[1.0.0, )" + } + }, + "werkr.servicedefaults": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Microsoft.AspNetCore.Authorization": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "6SEGWi35DZ9syBqCT8v5vEkm9tWUayWxVkHWLwW2FdyXSwS0zzEpIzGPLVQGeug3VU8d+hK/PFxFwwZnblv/zA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Identity.Stores": "10.0.3" + } + }, + "Microsoft.AspNetCore.Identity.UI": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xhxrP7QcUuyA2FcZsbvdHSqTauPseNrXzhFUYaRj+Elz1nxJceKbW+COc1P9QbpKeZDh9aTDSldHbz3AnMWOqg==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Embedded": "10.0.3", + "Microsoft.Extensions.Identity.Stores": "10.0.3" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.3.0", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Resilience": "10.3.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.ServiceDiscovery": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "OpenTelemetry": "1.15.0" + } + }, + "QRCoder": { + "type": "CentralTransitive", + "requested": "[1.7.0, )", + "resolved": "1.7.0", + "contentHash": "6R3hQkayihGIDjp3F1nLRDBWG+nqahGyOY2+fH4Rll16Vad67oaUUfHkOiMWKiJFnGh+PIGDfUos+0R9m54O1g==", + "dependencies": { + "System.Drawing.Common": "6.0.0" + } + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Sinks.OpenTelemetry": { + "type": "CentralTransitive", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", + "dependencies": { + "Google.Protobuf": "3.30.1", + "Grpc.Net.Client": "2.70.0", + "Serilog": "4.2.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" + } + } + } +} \ No newline at end of file diff --git a/src/Test/Werkr.Tests/AppHostFixture.cs b/src/Test/Werkr.Tests/AppHostFixture.cs new file mode 100644 index 0000000..729faf5 --- /dev/null +++ b/src/Test/Werkr.Tests/AppHostFixture.cs @@ -0,0 +1,239 @@ +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Testcontainers.PostgreSql; +using Werkr.Api; +using Werkr.Common; +using Werkr.Common.Auth; +using Werkr.Data; +using Werkr.Data.Identity; +using Werkr.Data.Identity.Entities; +using Werkr.Data.Identity.Roles; + +namespace Werkr.Tests; + +/// +/// Assembly-wide test fixture that starts a Postgres Testcontainer, boots the Werkr API +/// via , runs DB migrations, and exposes +/// a pre-authenticated . +/// +[TestClass] +public static class AppHostFixture { + private static PostgreSqlContainer? s_postgres; + private static WebApplicationFactory? s_factory; + + /// Pre-built JSON options matching the API's camelCase convention. + public static JsonSerializerOptions JsonOptions { get; } = new( JsonSerializerDefaults.Web ); + + /// Authenticated HttpClient targeting the API. Carries an admin JWT. + public static HttpClient ApiClient { get; private set; } = null!; + + [AssemblyInitialize] + public static async Task InitializeAsync( TestContext testContext ) { + CancellationToken ct = testContext.CancellationToken; + + // ── 1. Start a disposable Postgres container ───────────────── + s_postgres = new PostgreSqlBuilder( "postgres:17-alpine" ) + .Build( ); + + await s_postgres.StartAsync( ct ); + + string connStr = s_postgres.GetConnectionString( ); + + // ── 2. Create the in-process API server ────────────────────── + s_factory = new WebApplicationFactory( ) + .WithWebHostBuilder( builder => { + _ = builder.UseEnvironment( "Development" ); + + // Provide settings that Program.Main reads before Build(): + // ConnectionStrings:werkrdb, Jwt:SigningKey, Jwt:Issuer, Jwt:Audience + _ = builder.UseSetting( "ConnectionStrings:werkrdb", connStr ); + _ = builder.UseSetting( "Jwt:SigningKey", + "werkr-dev-signing-key-do-not-use-in-production-min32chars!" ); + _ = builder.UseSetting( "Jwt:Issuer", "werkr-api" ); + _ = builder.UseSetting( "Jwt:Audience", "werkr" ); + + _ = builder.ConfigureServices( services => { + // Remove the database context registrations added by Program.Main + // (which captured an empty connection string) and re-register + // them with the Testcontainer's connection string. + _ = services.RemoveAll( typeof( DbContextOptions ) ); + _ = services.RemoveAll( typeof( DbContextOptions ) ); + _ = services.RemoveAll( typeof( PostgresWerkrDbContext ) ); + _ = services.RemoveAll( typeof( WerkrDbContext ) ); + + _ = services.RemoveAll( typeof( DbContextOptions ) ); + _ = services.RemoveAll( typeof( DbContextOptions ) ); + _ = services.RemoveAll( typeof( DbContextOptions ) ); + _ = services.RemoveAll( typeof( PostgresWerkrIdentityDbContext ) ); + _ = services.RemoveAll( typeof( WerkrIdentityDbContext ) ); + + _ = services.AddWerkrDbContext( DatabaseProvider.Postgres, connStr ); + + // Register Identity DbContext via provider-specific subclass + + // forwarding, matching the dual-provider pattern in AddWerkrIdentity. + _ = services.AddDbContext( options => { + _ = options.UseNpgsql( connStr, npgsql => + npgsql.MigrationsHistoryTable( "__EFMigrationsHistory", "werkr_identity" ) ) + .UseSnakeCaseNamingConvention( ); + } ); + _ = services.AddScoped( sp => + sp.GetRequiredService( ) ); + + _ = services.AddIdentityCore( + Werkr.Data.Identity.Extensions.IdentityExtensions.ConfigureIdentityOptions ) + .AddRoles( ) + .AddEntityFrameworkStores( ) + .AddDefaultTokenProviders( ); + } ); + } ); + + // ── 3. Run EF Core migrations ──────────────────────────────── + using (IServiceScope scope = s_factory.Services.CreateScope( )) { + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + await db.Database.MigrateAsync( ct ); + + WerkrIdentityDbContext identityDb = scope.ServiceProvider + .GetRequiredService( ); + await identityDb.Database.MigrateAsync( ct ); + } + + // ── 3b. Seed Identity roles & permissions ──────────────────── + // The production IdentitySeeder lives in Werkr.Server, which + // has heavy dependencies (Blazor, SignalR, ServerConfigCache). + // Instead of pulling all of those in, replicate the minimal + // role/permission seed here so the permission-based auth + // pipeline resolves correctly. + await SeedRolesAndPermissionsAsync( s_factory.Services, ct ); + + // ── 4. Create an authenticated API client ──────────────────── + HttpClient apiClient = s_factory.CreateClient( ); + apiClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue( "Bearer", GenerateAdminJwt( ) ); + ApiClient = apiClient; + } + + [AssemblyCleanup] + public static async Task CleanupAsync( ) { + ApiClient?.Dispose( ); + s_factory?.Dispose( ); + + if (s_postgres is not null) { + await s_postgres.DisposeAsync( ); + s_postgres = null; + } + } + + // ────────────────────────────────────────────────────────────────────── + // Identity seed — roles + role-permission mappings. + // ────────────────────────────────────────────────────────────────────── + + /// + /// Mirrors the role/permission seed from Werkr.Server.Identity.IdentitySeeder + /// without dragging in the full Werkr.Server dependency graph. + /// + private static async Task SeedRolesAndPermissionsAsync( + IServiceProvider services, CancellationToken ct ) { + Dictionary permissionMap = new( ) { + [DefaultRoles.Admin] = [ + Permission.Create, + Permission.Read, + Permission.Update, + Permission.Delete, + Permission.Execute, + Permission.Admin, + ], + [DefaultRoles.Operator] = [ + Permission.Read, + Permission.Execute, + ], + [DefaultRoles.Viewer] = [ + Permission.Read, + ], + }; + + using IServiceScope scope = services.CreateScope( ); + RoleManager roleManager = scope.ServiceProvider + .GetRequiredService>( ); + WerkrIdentityDbContext dbContext = scope.ServiceProvider + .GetRequiredService( ); + + // Create roles + foreach (string role in Enum.GetNames( )) { + if (!await roleManager.RoleExistsAsync( role )) { + _ = await roleManager.CreateAsync( new IdentityRole( role ) ); + } + } + + // Create role-permission mappings + foreach ((DefaultRoles defaultRole, Permission[] permissions) in permissionMap) { + IdentityRole? role = await roleManager.FindByNameAsync( defaultRole.ToString( ) ); + if (role is null) { + continue; + } + + foreach (Permission permission in permissions) { + bool exists = await dbContext.RolePermissions + .AnyAsync( rp => rp.RoleId == role.Id && rp.Permission == permission, ct ); + + if (!exists) { + _ = dbContext.RolePermissions.Add( new RolePermission { + RoleId = role.Id, + Permission = permission, + } ); + } + } + } + + _ = await dbContext.SaveChangesAsync( ct ); + } + + // ────────────────────────────────────────────────────────────────────── + // JWT helper — mirrors the known dev-only signing config. + // ────────────────────────────────────────────────────────────────────── + + /// + /// Generate a short-lived admin JWT signed with the development signing key. + /// Matches the configuration in Werkr.Api/appsettings.Development.json. + /// + private static string GenerateAdminJwt( ) { + const string SigningKey = "werkr-dev-signing-key-do-not-use-in-production-min32chars!"; + const string Issuer = "werkr-api"; + const string Audience = "werkr"; + + SymmetricSecurityKey key = new( Encoding.UTF8.GetBytes( SigningKey ) ); + SigningCredentials credentials = new( key, SecurityAlgorithms.HmacSha256 ); + + Claim[] claims = [ + new( ClaimTypes.NameIdentifier, Guid.NewGuid( ).ToString( ) ), + new( ClaimTypes.Role, "Admin" ), + new( WerkrClaimTypes.ApiKeyId, Guid.NewGuid( ).ToString( ) ), + new( WerkrClaimTypes.ApiKeyName, "integration-test-key" ), + new( JwtRegisteredClaimNames.Jti, Guid.NewGuid( ).ToString( ) ), + // Permission claims required by ClaimsPermissionAuthorizationHandler + new( WerkrClaimTypes.Permission, Permission.Create.ToString( ) ), + new( WerkrClaimTypes.Permission, Permission.Read.ToString( ) ), + new( WerkrClaimTypes.Permission, Permission.Update.ToString( ) ), + new( WerkrClaimTypes.Permission, Permission.Delete.ToString( ) ), + new( WerkrClaimTypes.Permission, Permission.Execute.ToString( ) ), + new( WerkrClaimTypes.Permission, Permission.Admin.ToString( ) ), + ]; + + SecurityTokenDescriptor descriptor = new( ) { + Issuer = Issuer, + Audience = Audience, + Subject = new ClaimsIdentity( claims ), + Expires = DateTime.UtcNow.AddHours( 1 ), + SigningCredentials = credentials, + }; + + return new JsonWebTokenHandler( ).CreateToken( descriptor ); + } +} diff --git a/src/Test/Werkr.Tests/Integration/ActionDispatchIntegrationTests.cs b/src/Test/Werkr.Tests/Integration/ActionDispatchIntegrationTests.cs new file mode 100644 index 0000000..4bf6e94 --- /dev/null +++ b/src/Test/Werkr.Tests/Integration/ActionDispatchIntegrationTests.cs @@ -0,0 +1,444 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace Werkr.Tests.Integration; + +/// +/// Integration tests for Action-type task CRUD and validation through the REST API. +/// Validates TaskMapper.ValidateActionFields, parameter deserialization, and +/// the full persistence round-trip for Action tasks. +/// All tests share the instance. +/// +[TestClass] +public class ActionDispatchIntegrationTests { + + public TestContext TestContext { get; set; } = null!; + + private static JsonSerializerOptions JsonOptions => AppHostFixture.JsonOptions; + private static HttpClient Api => AppHostFixture.ApiClient; + + #region Helper Methods + + /// + /// Creates an Action-type task via the REST API. Returns the parsed JSON response. + /// + private static async Task CreateActionTaskAsync( + string name, + string actionSubType, + object parameters, + CancellationToken ct ) { + + string parametersJson = JsonSerializer.Serialize( parameters, JsonOptions ); + + var request = new { + name, + description = $"Integration test action task: {name}", + actionType = "Action", + content = "", + targetTags = new[] { "integration-test" }, + enabled = true, + timeoutMinutes = 5L, + actionSubType, + actionParameters = parametersJson, + }; + + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/tasks", request, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, response.StatusCode, + $"Action task creation failed: {await response.Content.ReadAsStringAsync( ct )}" ); + + return await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + } + + #endregion Helper Methods + + #region Action Task CRUD + + [TestMethod] + [Timeout( 60_000 )] + public async Task CreateActionTask_CopyFile_PersistsAndReturnsCorrectData( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement created = await CreateActionTaskAsync( + "IntTest_ActionCrud_CopyFile", + "CopyFile", + new { source = "/tmp/src.txt", destination = "/tmp/dst.txt", overwrite = true }, + ct ); + + long taskId = created.GetProperty( "id" ).GetInt64( ); + Assert.IsGreaterThan( 0L, taskId, "Task ID should be positive." ); + Assert.AreEqual( "Action", created.GetProperty( "actionType" ).GetString( ) ); + Assert.AreEqual( "CopyFile", created.GetProperty( "actionSubType" ).GetString( ) ); + Assert.IsNotNull( created.GetProperty( "actionParameters" ).GetString( ), + "ActionParameters should be persisted." ); + + // Read back + HttpResponseMessage getResponse = await Api.GetAsync( $"/api/tasks/{taskId}", ct ); + Assert.AreEqual( HttpStatusCode.OK, getResponse.StatusCode ); + + JsonElement retrieved = await getResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( "CopyFile", retrieved.GetProperty( "actionSubType" ).GetString( ) ); + Assert.AreEqual( "Action", retrieved.GetProperty( "actionType" ).GetString( ) ); + + // Verify parameters round-trip + string? paramsJson = retrieved.GetProperty( "actionParameters" ).GetString( ); + Assert.IsNotNull( paramsJson ); + using JsonDocument parsedParams = JsonDocument.Parse( paramsJson ); + Assert.AreEqual( "/tmp/src.txt", parsedParams.RootElement.GetProperty( "source" ).GetString( ) ); + Assert.AreEqual( "/tmp/dst.txt", parsedParams.RootElement.GetProperty( "destination" ).GetString( ) ); + Assert.IsTrue( parsedParams.RootElement.GetProperty( "overwrite" ).GetBoolean( ) ); + + // Cleanup + _ = await Api.DeleteAsync( $"/api/tasks/{taskId}", ct ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task CreateActionTask_AllActionTypes_Succeed( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Each action type with minimal valid parameters + (string subType, object parameters)[] actionTypes = [ + ("CopyFile", new { source = "/a", destination = "/b" }), + ("MoveFile", new { source = "/a", destination = "/b" }), + ("RenameFile", new { path = "/a/file.txt", newName = "renamed.txt" }), + ("DeleteFile", new { path = "/a/file.txt" }), + ("CreateFile", new { path = "/a/new.txt" }), + ("CreateDirectory", new { path = "/a/newdir" }), + ("TestExists", new { path = "/a/file.txt" }), + ("ClearContent", new { path = "/a/file.txt" }), + ("WriteContent", new { path = "/a/file.txt", content = "data" }), + ("StartProcess", new { fileName = "echo", arguments = "hello" }), + ("StopProcess", new { processName = "notepad" }), + ]; + + List createdIds = []; + + foreach ((string subType, object parameters) in actionTypes) { + JsonElement created = await CreateActionTaskAsync( + $"IntTest_AllActions_{subType}", subType, parameters, ct ); + + long taskId = created.GetProperty( "id" ).GetInt64( ); + Assert.IsGreaterThan( 0L, taskId, $"{subType} task should have a positive ID." ); + Assert.AreEqual( subType, created.GetProperty( "actionSubType" ).GetString( ), + $"ActionSubType should match '{subType}'." ); + createdIds.Add( taskId ); + } + + // Cleanup + foreach (long id in createdIds) { + _ = await Api.DeleteAsync( $"/api/tasks/{id}", ct ); + } + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task UpdateActionTask_ChangesActionParameters( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement created = await CreateActionTaskAsync( + "IntTest_ActionUpdate", + "CreateFile", + new { path = "/tmp/original.txt", content = "original" }, + ct ); + + long taskId = created.GetProperty( "id" ).GetInt64( ); + + // Update to WriteContent action + string updatedParams = JsonSerializer.Serialize( + new { path = "/tmp/updated.txt", content = "updated", append = true }, + JsonOptions ); + + var updateRequest = new { + name = "IntTest_ActionUpdate_Modified", + description = "Updated action task", + actionType = "Action", + content = "", + targetTags = new[] { "integration-test" }, + enabled = true, + timeoutMinutes = 10L, + actionSubType = "WriteContent", + actionParameters = updatedParams, + }; + + HttpResponseMessage putResponse = await Api.PutAsJsonAsync( + $"/api/tasks/{taskId}", updateRequest, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.OK, putResponse.StatusCode, + $"Task update failed: {await putResponse.Content.ReadAsStringAsync( ct )}" ); + + JsonElement updated = await putResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( "WriteContent", updated.GetProperty( "actionSubType" ).GetString( ) ); + Assert.AreEqual( "IntTest_ActionUpdate_Modified", updated.GetProperty( "name" ).GetString( ) ); + + // Verify new parameters persisted + string? paramsJson = updated.GetProperty( "actionParameters" ).GetString( ); + Assert.IsNotNull( paramsJson ); + using JsonDocument parsedParams = JsonDocument.Parse( paramsJson ); + Assert.AreEqual( "/tmp/updated.txt", parsedParams.RootElement.GetProperty( "path" ).GetString( ) ); + Assert.IsTrue( parsedParams.RootElement.GetProperty( "append" ).GetBoolean( ) ); + + // Cleanup + _ = await Api.DeleteAsync( $"/api/tasks/{taskId}", ct ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task DeleteActionTask_RemovesAndReturnsNotFound( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement created = await CreateActionTaskAsync( + "IntTest_ActionDelete", + "DeleteFile", + new { path = "/tmp/todelete.txt", recursive = true }, + ct ); + + long taskId = created.GetProperty( "id" ).GetInt64( ); + + HttpResponseMessage deleteResponse = await Api.DeleteAsync( $"/api/tasks/{taskId}", ct ); + Assert.AreEqual( HttpStatusCode.NoContent, deleteResponse.StatusCode ); + + HttpResponseMessage notFoundResponse = await Api.GetAsync( $"/api/tasks/{taskId}", ct ); + Assert.AreEqual( HttpStatusCode.NotFound, notFoundResponse.StatusCode ); + } + + #endregion Action Task CRUD + + #region Validation — ActionSubType + + [TestMethod] + [Timeout( 60_000 )] + public async Task CreateActionTask_MissingActionSubType_ReturnsBadRequest( ) { + CancellationToken ct = TestContext.CancellationToken; + + var request = new { + name = "IntTest_MissingSubType", + description = "Should fail validation", + actionType = "Action", + content = "", + targetTags = new[] { "integration-test" }, + enabled = true, + actionSubType = (string?) null, + actionParameters = """{"path":"/tmp/test.txt"}""", + }; + + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/tasks", request, JsonOptions, ct ); + + Assert.AreEqual( HttpStatusCode.BadRequest, response.StatusCode, + "Missing ActionSubType should return 400 Bad Request." ); + + JsonElement body = await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + StringAssert.Contains( + body.GetProperty( "message" ).GetString( )!, + "ActionSubType", + "Error message should reference the missing ActionSubType field." ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task CreateActionTask_UnknownActionSubType_ReturnsBadRequest( ) { + CancellationToken ct = TestContext.CancellationToken; + + var request = new { + name = "IntTest_UnknownSubType", + description = "Should fail validation", + actionType = "Action", + content = "", + targetTags = new[] { "integration-test" }, + enabled = true, + actionSubType = "FlyToMoon", + actionParameters = """{"rocket":"saturn-v"}""", + }; + + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/tasks", request, JsonOptions, ct ); + + Assert.AreEqual( HttpStatusCode.BadRequest, response.StatusCode, + "Unknown ActionSubType should return 400 Bad Request." ); + + JsonElement body = await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + StringAssert.Contains( + body.GetProperty( "message" ).GetString( )!, + "FlyToMoon", + "Error message should reference the unknown action name." ); + } + + #endregion Validation — ActionSubType + + #region Validation — ActionParameters + + [TestMethod] + [Timeout( 60_000 )] + public async Task CreateActionTask_MissingActionParameters_ReturnsBadRequest( ) { + CancellationToken ct = TestContext.CancellationToken; + + var request = new { + name = "IntTest_MissingParams", + description = "Should fail validation", + actionType = "Action", + content = "", + targetTags = new[] { "integration-test" }, + enabled = true, + actionSubType = "CreateFile", + actionParameters = (string?) null, + }; + + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/tasks", request, JsonOptions, ct ); + + Assert.AreEqual( HttpStatusCode.BadRequest, response.StatusCode, + "Missing ActionParameters should return 400 Bad Request." ); + + JsonElement body = await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + StringAssert.Contains( + body.GetProperty( "message" ).GetString( )!, + "ActionParameters", + "Error message should reference the missing ActionParameters field." ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task CreateActionTask_MalformedJsonParameters_ReturnsBadRequest( ) { + CancellationToken ct = TestContext.CancellationToken; + + var request = new { + name = "IntTest_MalformedJson", + description = "Should fail validation", + actionType = "Action", + content = "", + targetTags = new[] { "integration-test" }, + enabled = true, + actionSubType = "CreateFile", + actionParameters = "not-valid-json!!!", + }; + + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/tasks", request, JsonOptions, ct ); + + Assert.AreEqual( HttpStatusCode.BadRequest, response.StatusCode, + "Malformed JSON in ActionParameters should return 400 Bad Request." ); + } + + #endregion Validation — ActionParameters + + #region Validation — Non-Action Tasks Reject Action Fields + + [TestMethod] + [Timeout( 60_000 )] + public async Task CreateShellTask_WithActionSubType_ReturnsBadRequest( ) { + CancellationToken ct = TestContext.CancellationToken; + + var request = new { + name = "IntTest_ShellWithSubType", + description = "Shell task should not have ActionSubType", + actionType = "ShellCommand", + content = "echo hello", + targetTags = new[] { "integration-test" }, + enabled = true, + actionSubType = "CopyFile", + }; + + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/tasks", request, JsonOptions, ct ); + + Assert.AreEqual( HttpStatusCode.BadRequest, response.StatusCode, + "ShellCommand task with ActionSubType should return 400 Bad Request." ); + + JsonElement body = await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + StringAssert.Contains( + body.GetProperty( "message" ).GetString( )!, + "ActionSubType", + "Error message should explain ActionSubType must be null for non-Action tasks." ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task CreateShellTask_WithActionParameters_ReturnsBadRequest( ) { + CancellationToken ct = TestContext.CancellationToken; + + var request = new { + name = "IntTest_ShellWithParams", + description = "Shell task should not have ActionParameters", + actionType = "ShellCommand", + content = "echo hello", + targetTags = new[] { "integration-test" }, + enabled = true, + actionParameters = """{"path":"/tmp/test.txt"}""", + }; + + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/tasks", request, JsonOptions, ct ); + + Assert.AreEqual( HttpStatusCode.BadRequest, response.StatusCode, + "ShellCommand task with ActionParameters should return 400 Bad Request." ); + + JsonElement body = await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + StringAssert.Contains( + body.GetProperty( "message" ).GetString( )!, + "ActionParameters", + "Error message should explain ActionParameters must be null for non-Action tasks." ); + } + + #endregion Validation — Non-Action Tasks Reject Action Fields + + #region Ad-Hoc Execution — No Agent + + [TestMethod] + [Timeout( 60_000 )] + public async Task AdHocRunActionTask_WithoutConnectedAgent_ReturnsConflict( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement task = await CreateActionTaskAsync( + "IntTest_AdHocActionRun", + "TestExists", + new { path = "/tmp/check.txt", type = 2 }, // PathType.Any = 2 + ct ); + + long taskId = task.GetProperty( "id" ).GetInt64( ); + + HttpResponseMessage runResponse = await Api.PostAsJsonAsync( + $"/api/tasks/{taskId}/run", new object( ), JsonOptions, ct ); + + Assert.AreEqual( HttpStatusCode.Conflict, runResponse.StatusCode, + "Ad-hoc action run should return 409 Conflict when no agent matches the target tags." ); + + string body = await runResponse.Content.ReadAsStringAsync( ct ); + StringAssert.Contains( body, "No connected agent", + "Conflict response should describe that no matching agent was found." ); + + // Cleanup + _ = await Api.DeleteAsync( $"/api/tasks/{taskId}", ct ); + } + + #endregion Ad-Hoc Execution — No Agent + + #region Job History — New Action Task + + [TestMethod] + [Timeout( 60_000 )] + public async Task JobHistory_ForNewActionTask_ReturnsEmptyList( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement task = await CreateActionTaskAsync( + "IntTest_ActionJobHistory", + "CreateDirectory", + new { path = "/tmp/newdir" }, + ct ); + + long taskId = task.GetProperty( "id" ).GetInt64( ); + + HttpResponseMessage jobsResponse = await Api.GetAsync( $"/api/tasks/{taskId}/jobs", ct ); + Assert.AreEqual( HttpStatusCode.OK, jobsResponse.StatusCode ); + + JsonElement jobList = await jobsResponse.Content + .ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( JsonValueKind.Array, jobList.ValueKind ); + Assert.AreEqual( 0, jobList.GetArrayLength( ), + "A newly created action task should have no job history." ); + + // Cleanup + _ = await Api.DeleteAsync( $"/api/tasks/{taskId}", ct ); + } + + #endregion Job History — New Action Task +} diff --git a/src/Test/Werkr.Tests/Integration/HolidayCalendarIntegrationTests.cs b/src/Test/Werkr.Tests/Integration/HolidayCalendarIntegrationTests.cs new file mode 100644 index 0000000..5dc64c6 --- /dev/null +++ b/src/Test/Werkr.Tests/Integration/HolidayCalendarIntegrationTests.cs @@ -0,0 +1,584 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace Werkr.Tests.Integration; + +/// +/// Integration tests for the Holiday Calendar REST API endpoints. +/// Tests exercise the Werkr API via (Testcontainers + WebApplicationFactory). +/// +[TestClass] +public class HolidayCalendarIntegrationTests { + public TestContext TestContext { get; set; } = null!; + + private static JsonSerializerOptions JsonOptions => AppHostFixture.JsonOptions; + private static HttpClient Api => AppHostFixture.ApiClient; + + #region Helpers + + private static async Task CreateCalendarAsync( + string name, string description, CancellationToken ct ) { + var request = new { name, description }; + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/holiday-calendars", request, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, response.StatusCode, + $"Calendar creation failed: {await response.Content.ReadAsStringAsync( ct )}" ); + return await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + } + + private static async Task CreateDailyScheduleAndReturnIdAsync( + string name, CancellationToken ct ) { + var request = new { + name, + stopTaskAfterMinutes = 60L, + startDateTime = new { date = "2026-06-15", time = "09:00:00", timeZoneId = "UTC" }, + dailyRecurrence = new { dayInterval = 1 }, + }; + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/schedules", request, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, response.StatusCode ); + JsonElement json = await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + return json.GetProperty( "id" ).GetString( )!; + } + + private static string GetId( JsonElement element ) => + element.GetProperty( "id" ).GetString( )!; + + #endregion + + // ── CRUD ─────────────────────────────────────────────────────────────────── + + [TestMethod] + [Timeout( 60_000 )] + public async Task CreateCalendar_RoundTrips( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement created = await CreateCalendarAsync( "IntTest_CRUD", "Created via integration test", ct ); + string calId = GetId( created ); + Assert.IsFalse( string.IsNullOrEmpty( calId ) ); + + HttpResponseMessage getResp = await Api.GetAsync( $"/api/holiday-calendars/{calId}", ct ); + Assert.AreEqual( HttpStatusCode.OK, getResp.StatusCode ); + + JsonElement fetched = await getResp.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( "IntTest_CRUD", fetched.GetProperty( "name" ).GetString( ) ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task DeleteSystemCalendar_Returns403( ) { + CancellationToken ct = TestContext.CancellationToken; + + // The system calendars are seeded at startup. List all and find one. + HttpResponseMessage listResp = await Api.GetAsync( "/api/holiday-calendars", ct ); + Assert.AreEqual( HttpStatusCode.OK, listResp.StatusCode ); + + JsonElement[] all = await listResp.Content.ReadFromJsonAsync( JsonOptions, ct ) + ?? []; + + JsonElement? systemCal = all.FirstOrDefault( c => + c.GetProperty( "isSystemCalendar" ).GetBoolean( ) ); + + if (systemCal is null) { + Assert.Inconclusive( "No system calendars found - seeder may not have run." ); + return; + } + + string sysId = GetId( systemCal.Value ); + HttpResponseMessage delResp = await Api.DeleteAsync( $"/api/holiday-calendars/{sysId}", ct ); + + // Should reject modification of system calendar (400 or 403) + Assert.AreNotEqual( HttpStatusCode.OK, delResp.StatusCode ); + Assert.AreNotEqual( HttpStatusCode.NoContent, delResp.StatusCode ); + } + + // ── Clone ────────────────────────────────────────────────────────────────── + + [TestMethod] + [Timeout( 60_000 )] + public async Task CloneSystemCalendar_CreatesEditableCopy( ) { + CancellationToken ct = TestContext.CancellationToken; + + HttpResponseMessage listResp = await Api.GetAsync( "/api/holiday-calendars", ct ); + JsonElement[] all = await listResp.Content.ReadFromJsonAsync( JsonOptions, ct ) + ?? []; + + JsonElement? systemCal = all.FirstOrDefault( c => + c.GetProperty( "isSystemCalendar" ).GetBoolean( ) ); + + if (systemCal is null) { + Assert.Inconclusive( "No system calendars found." ); + return; + } + + string sysId = GetId( systemCal.Value ); + var cloneReq = new { newName = "IntTest_Cloned" }; + HttpResponseMessage cloneResp = await Api.PostAsJsonAsync( + $"/api/holiday-calendars/{sysId}/clone", cloneReq, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, cloneResp.StatusCode ); + + JsonElement cloned = await cloneResp.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.IsFalse( cloned.GetProperty( "isSystemCalendar" ).GetBoolean( ) ); + Assert.AreEqual( "IntTest_Cloned", cloned.GetProperty( "name" ).GetString( ) ); + } + + // ── Rules ────────────────────────────────────────────────────────────────── + + [TestMethod] + [Timeout( 60_000 )] + public async Task AddRule_InvalidatesCache( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement cal = await CreateCalendarAsync( "IntTest_Rules", "Test rules", ct ); + string calId = GetId( cal ); + + var rule = new { + name = "Test Holiday", + ruleType = "FixedDate", + month = 7, + day = 4, + observanceRule = "None", + }; + + HttpResponseMessage addResp = await Api.PostAsJsonAsync( + $"/api/holiday-calendars/{calId}/rules", rule, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, addResp.StatusCode ); + + // Verify rule exists + HttpResponseMessage getRulesResp = await Api.GetAsync( + $"/api/holiday-calendars/{calId}/rules", ct ); + Assert.AreEqual( HttpStatusCode.OK, getRulesResp.StatusCode ); + + JsonElement[] rules = await getRulesResp.Content.ReadFromJsonAsync( JsonOptions, ct ) + ?? []; + Assert.HasCount( 1, rules ); + } + + // ── Manual Dates ─────────────────────────────────────────────────────────── + + [TestMethod] + [Timeout( 60_000 )] + public async Task AddManualDate_Persists( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement cal = await CreateCalendarAsync( "IntTest_ManualDates", "Test manual dates", ct ); + string calId = GetId( cal ); + + var date = new { + date = "2026-03-15", + name = "Company Holiday", + year = 2026, + }; + + HttpResponseMessage addResp = await Api.PostAsJsonAsync( + $"/api/holiday-calendars/{calId}/dates", date, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, addResp.StatusCode ); + + HttpResponseMessage getDatesResp = await Api.GetAsync( + $"/api/holiday-calendars/{calId}/dates", ct ); + Assert.AreEqual( HttpStatusCode.OK, getDatesResp.StatusCode ); + + JsonElement[] dates = await getDatesResp.Content.ReadFromJsonAsync( JsonOptions, ct ) + ?? []; + Assert.HasCount( 1, dates ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task BulkAddManualDates_PersistsAll( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement cal = await CreateCalendarAsync( "IntTest_BulkDates", "Test bulk dates", ct ); + string calId = GetId( cal ); + + var dates = new { + dates = new[] { + new { date = "2026-03-01", name = "Holiday A" }, + new { date = "2026-06-01", name = "Holiday B" }, + new { date = "2026-09-01", name = "Holiday C" }, + }, + }; + + HttpResponseMessage addResp = await Api.PostAsJsonAsync( + $"/api/holiday-calendars/{calId}/dates/bulk", dates, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, addResp.StatusCode ); + } + + // ── Rule Preview ─────────────────────────────────────────────────────────── + + [TestMethod] + [Timeout( 60_000 )] + public async Task PreviewRule_ReturnsCorrectDates( ) { + CancellationToken ct = TestContext.CancellationToken; + + var rule = new { + name = "July 4", + ruleType = "FixedDate", + month = 7, + day = 4, + observanceRule = "SaturdayToFriday_SundayToMonday", + }; + + HttpResponseMessage previewResp = await Api.PostAsJsonAsync( + "/api/holiday-calendars/rules/preview?startYear=2025&endYear=2027", rule, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.OK, previewResp.StatusCode ); + + JsonElement previewResult = await previewResp.Content.ReadFromJsonAsync( JsonOptions, ct ); + JsonElement[] dates = [.. previewResult.GetProperty( "dates" ).EnumerateArray( )]; + Assert.HasCount( 3, dates ); + } + + // ── Schedule Attachment ──────────────────────────────────────────────────── + + [TestMethod] + [Timeout( 60_000 )] + public async Task AttachCalendar_GetAttachment_Detach( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement cal = await CreateCalendarAsync( "IntTest_Attach", "Test attach", ct ); + string calId = GetId( cal ); + string schedId = await CreateDailyScheduleAndReturnIdAsync( "IntTest_Sched_Attach", ct ); + + // Attach + var attachReq = new { + calendarId = calId, + mode = "Blocklist", + }; + HttpResponseMessage attachResp = await Api.PutAsJsonAsync( + $"/api/schedules/{schedId}/holiday-calendar", attachReq, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.OK, attachResp.StatusCode ); + + // Get + HttpResponseMessage getResp = await Api.GetAsync( + $"/api/schedules/{schedId}/holiday-calendar", ct ); + Assert.AreEqual( HttpStatusCode.OK, getResp.StatusCode ); + + JsonElement attached = await getResp.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( "Blocklist", attached.GetProperty( "mode" ).GetString( ) ); + + // Detach + HttpResponseMessage detachResp = await Api.DeleteAsync( + $"/api/schedules/{schedId}/holiday-calendar", ct ); + Assert.AreEqual( HttpStatusCode.NoContent, detachResp.StatusCode ); + + // Verify detached + HttpResponseMessage getAfterDetachResp = await Api.GetAsync( + $"/api/schedules/{schedId}/holiday-calendar", ct ); + Assert.AreEqual( HttpStatusCode.NoContent, getAfterDetachResp.StatusCode ); + } + + // ── Calendar Preview (full calendar) ─────────────────────────────────────── + + [TestMethod] + [Timeout( 60_000 )] + public async Task CalendarPreview_ReturnsDatesForYearRange( ) { + CancellationToken ct = TestContext.CancellationToken; + + // List system calendars and pick one + HttpResponseMessage listResp = await Api.GetAsync( "/api/holiday-calendars", ct ); + JsonElement[] all = await listResp.Content.ReadFromJsonAsync( JsonOptions, ct ) + ?? []; + + JsonElement? systemCal = all.FirstOrDefault( c => + c.GetProperty( "isSystemCalendar" ).GetBoolean( ) ); + + if (systemCal is null) { + Assert.Inconclusive( "No system calendars found." ); + return; + } + + string sysId = GetId( systemCal.Value ); + HttpResponseMessage previewResp = await Api.GetAsync( + $"/api/holiday-calendars/{sysId}/preview?startYear=2026&endYear=2026", ct ); + Assert.AreEqual( HttpStatusCode.OK, previewResp.StatusCode ); + + JsonElement previewResult = await previewResp.Content.ReadFromJsonAsync( JsonOptions, ct ); + JsonElement[] dates = [.. previewResult.GetProperty( "dates" ).EnumerateArray( )]; + + // At least 10 holidays (US Federal has 11, Fed Reserve has 10) + Assert.IsGreaterThanOrEqualTo( 10, dates.Length ); + } + + // ── Audit Log ────────────────────────────────────────────────────────────── + + [TestMethod] + [Timeout( 60_000 )] + public async Task AuditLog_EmptyForNewSchedule( ) { + CancellationToken ct = TestContext.CancellationToken; + + string schedId = await CreateDailyScheduleAndReturnIdAsync( "IntTest_Audit_Empty", ct ); + + string from = DateTime.UtcNow.AddDays( -30 ).ToString( "O" ); + string to = DateTime.UtcNow.ToString( "O" ); + + HttpResponseMessage getResp = await Api.GetAsync( + $"/api/schedules/{schedId}/audit-log?from={from}&to={to}", ct ); + Assert.AreEqual( HttpStatusCode.OK, getResp.StatusCode ); + + JsonElement[] logs = await getResp.Content.ReadFromJsonAsync( JsonOptions, ct ) + ?? []; + Assert.IsEmpty( logs ); + } + + // ── Federal Reserve — Columbus Day (H13) ─────────────────────────────────── + + [TestMethod] + [Timeout( 60_000 )] + public async Task FedReserveCalendar_DoesNotIncludeColumbusDay( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Find Federal Reserve Holiday calendar by deterministic GUID + string fedReserveId = "a0000001-0000-0000-0000-000000000002"; + HttpResponseMessage getResp = await Api.GetAsync( + $"/api/holiday-calendars/{fedReserveId}", ct ); + + if (getResp.StatusCode == HttpStatusCode.NotFound) { + Assert.Inconclusive( "Federal Reserve calendar not found - seeder may not have run." ); + return; + } + + Assert.AreEqual( HttpStatusCode.OK, getResp.StatusCode ); + + // Get rules and verify no Columbus Day + HttpResponseMessage rulesResp = await Api.GetAsync( + $"/api/holiday-calendars/{fedReserveId}/rules", ct ); + Assert.AreEqual( HttpStatusCode.OK, rulesResp.StatusCode ); + + JsonElement[] rules = await rulesResp.Content.ReadFromJsonAsync( JsonOptions, ct ) + ?? []; + + // Fed Reserve should have 10 rules (US Federal has 11 = 10 + Columbus Day) + Assert.HasCount( 10, rules ); + + bool hasColumbus = rules.Any( r => + r.GetProperty( "name" ).GetString( )!.Contains( "Columbus", StringComparison.OrdinalIgnoreCase ) ); + Assert.IsFalse( hasColumbus, "Federal Reserve calendar should NOT include Columbus Day (Decision H13)" ); + } + + // ── Audit Log with data ──────────────────────────────────────────────────── + + [TestMethod] + [Timeout( 60_000 )] + public async Task SubmitAuditLog_PersistsAndRetrievesRecords( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Create calendar and schedule, then attach + JsonElement cal = await CreateCalendarAsync( "IntTest_AuditData", "Test audit persistence", ct ); + string calId = GetId( cal ); + string schedId = await CreateDailyScheduleAndReturnIdAsync( "IntTest_AuditData_Sched", ct ); + + var attachReq = new { calendarId = calId, mode = "Blocklist" }; + HttpResponseMessage attachResp = await Api.PutAsJsonAsync( + $"/api/schedules/{schedId}/holiday-calendar", attachReq, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.OK, attachResp.StatusCode ); + + // Submit an audit log record + DateTime occurrenceTime = DateTime.UtcNow.AddHours( -1 ); + var auditReq = new { + occurrenceUtcTime = occurrenceTime, + holidayName = "Test Holiday", + reason = "Blocked by Blocklist", + }; + HttpResponseMessage postResp = await Api.PostAsJsonAsync( + $"/api/schedules/{schedId}/audit-log", auditReq, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, postResp.StatusCode ); + + // Retrieve and verify + string from = DateTime.UtcNow.AddDays( -1 ).ToString( "O" ); + string to = DateTime.UtcNow.AddDays( 1 ).ToString( "O" ); + HttpResponseMessage getResp = await Api.GetAsync( + $"/api/schedules/{schedId}/audit-log?from={from}&to={to}", ct ); + Assert.AreEqual( HttpStatusCode.OK, getResp.StatusCode ); + + JsonElement[] logs = await getResp.Content.ReadFromJsonAsync( JsonOptions, ct ) + ?? []; + Assert.HasCount( 1, logs ); + Assert.AreEqual( "Test Holiday", logs[0].GetProperty( "holidayName" ).GetString( ) ); + } + + // ── Occurrence Filtering ─────────────────────────────────────────────────── + + [TestMethod] + [Timeout( 60_000 )] + public async Task OccurrencePreview_BlocklistMode_SuppressesHolidays( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Create calendar with July 4 fixed-date rule + JsonElement cal = await CreateCalendarAsync( "IntTest_Blocklist", "Blocklist filtering", ct ); + string calId = GetId( cal ); + + var rule = new { + name = "July 4", + ruleType = "FixedDate", + month = 7, + day = 4, + observanceRule = "None", + }; + HttpResponseMessage addRuleResp = await Api.PostAsJsonAsync( + $"/api/holiday-calendars/{calId}/rules", rule, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, addRuleResp.StatusCode ); + + // Create daily schedule starting July 1 2026 + var schedReq = new { + name = "IntTest_BlocklistSched", + stopTaskAfterMinutes = 60L, + startDateTime = new { date = "2026-07-01", time = "09:00:00", timeZoneId = "UTC" }, + dailyRecurrence = new { dayInterval = 1 }, + }; + HttpResponseMessage schedResp = await Api.PostAsJsonAsync( + "/api/schedules", schedReq, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, schedResp.StatusCode ); + JsonElement schedJson = await schedResp.Content.ReadFromJsonAsync( JsonOptions, ct ); + string schedId = schedJson.GetProperty( "id" ).GetString( )!; + + // Attach as blocklist + var attachReq = new { calendarId = calId, mode = "Blocklist" }; + HttpResponseMessage attachResp = await Api.PutAsJsonAsync( + $"/api/schedules/{schedId}/holiday-calendar", attachReq, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.OK, attachResp.StatusCode ); + + // Preview occurrences for July 1-7 window + string windowEnd = new DateTime( 2026, 7, 8, 0, 0, 0, DateTimeKind.Utc ).ToString( "O" ); + HttpResponseMessage previewResp = await Api.GetAsync( + $"/api/schedules/{schedId}/occurrences?windowEnd={windowEnd}", ct ); + Assert.AreEqual( HttpStatusCode.OK, previewResp.StatusCode ); + + JsonElement preview = await previewResp.Content.ReadFromJsonAsync( JsonOptions, ct ); + JsonElement occurrences = preview.GetProperty( "occurrences" ); + + // July 4 should NOT be in the occurrence list (blocked) + bool containsJuly4 = occurrences.EnumerateArray( ).Any( o => { + DateTime dt = o.GetDateTime( ); + return dt.Month == 7 && dt.Day == 4; + } ); + Assert.IsFalse( containsJuly4, "July 4 should be suppressed by blocklist" ); + + // Suppressed list should contain July 4 + if (preview.TryGetProperty( "suppressed", out JsonElement suppressed ) && + suppressed.ValueKind == JsonValueKind.Array) { + Assert.IsGreaterThan( 0, suppressed.GetArrayLength( ), + "Suppressed list should contain at least one entry for July 4" ); + } + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task OccurrencePreview_AllowlistMode_KeepsOnlyHolidays( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Create calendar with July 4 fixed-date rule + JsonElement cal = await CreateCalendarAsync( "IntTest_Allowlist", "Allowlist filtering", ct ); + string calId = GetId( cal ); + + var rule = new { + name = "July 4", + ruleType = "FixedDate", + month = 7, + day = 4, + observanceRule = "None", + }; + HttpResponseMessage addRuleResp = await Api.PostAsJsonAsync( + $"/api/holiday-calendars/{calId}/rules", rule, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, addRuleResp.StatusCode ); + + // Create daily schedule starting July 1 2026 + var schedReq = new { + name = "IntTest_AllowlistSched", + stopTaskAfterMinutes = 60L, + startDateTime = new { date = "2026-07-01", time = "09:00:00", timeZoneId = "UTC" }, + dailyRecurrence = new { dayInterval = 1 }, + }; + HttpResponseMessage schedResp = await Api.PostAsJsonAsync( + "/api/schedules", schedReq, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, schedResp.StatusCode ); + JsonElement schedJson = await schedResp.Content.ReadFromJsonAsync( JsonOptions, ct ); + string schedId = schedJson.GetProperty( "id" ).GetString( )!; + + // Attach as allowlist + var attachReq = new { calendarId = calId, mode = "Allowlist" }; + HttpResponseMessage attachResp = await Api.PutAsJsonAsync( + $"/api/schedules/{schedId}/holiday-calendar", attachReq, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.OK, attachResp.StatusCode ); + + // Preview occurrences for July 1-7 window + string windowEnd = new DateTime( 2026, 7, 8, 0, 0, 0, DateTimeKind.Utc ).ToString( "O" ); + HttpResponseMessage previewResp = await Api.GetAsync( + $"/api/schedules/{schedId}/occurrences?windowEnd={windowEnd}", ct ); + Assert.AreEqual( HttpStatusCode.OK, previewResp.StatusCode ); + + JsonElement preview = await previewResp.Content.ReadFromJsonAsync( JsonOptions, ct ); + JsonElement occurrences = preview.GetProperty( "occurrences" ); + + // Allowlist: only July 4 should remain in occurrences + int count = occurrences.GetArrayLength( ); + Assert.AreEqual( 1, count, $"Allowlist should keep only the holiday date, but got {count} occurrences" ); + + DateTime keptDate = occurrences[0].GetDateTime( ); + Assert.AreEqual( 7, keptDate.Month ); + Assert.AreEqual( 4, keptDate.Day ); + } + + // ── Rule Mutation Invalidation ───────────────────────────────────────────── + + [TestMethod] + [Timeout( 60_000 )] + public async Task RuleMutation_InvalidatesCacheOnNextPreview( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement cal = await CreateCalendarAsync( "IntTest_Invalidation", "Test cache invalidation", ct ); + string calId = GetId( cal ); + + // Add rule for January + var rule = new { + name = "New Year's Day", + ruleType = "FixedDate", + month = 1, + day = 1, + observanceRule = "None", + }; + HttpResponseMessage addResp = await Api.PostAsJsonAsync( + $"/api/holiday-calendars/{calId}/rules", rule, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, addResp.StatusCode ); + + // Preview: should have January 1 + HttpResponseMessage preview1Resp = await Api.GetAsync( + $"/api/holiday-calendars/{calId}/preview?startYear=2026&endYear=2026", ct ); + Assert.AreEqual( HttpStatusCode.OK, preview1Resp.StatusCode ); + + JsonElement preview1Result = await preview1Resp.Content.ReadFromJsonAsync( JsonOptions, ct ); + JsonElement[] dates1 = [.. preview1Result.GetProperty( "dates" ).EnumerateArray( )]; + Assert.HasCount( 1, dates1 ); + + // Get the rule ID so we can update it + HttpResponseMessage rulesResp = await Api.GetAsync( + $"/api/holiday-calendars/{calId}/rules", ct ); + JsonElement[] rules = await rulesResp.Content.ReadFromJsonAsync( JsonOptions, ct ) + ?? []; + long ruleId = rules[0].GetProperty( "id" ).GetInt64( ); + + // Update rule: change from January to March + var updatedRule = new { + name = "Moved Holiday", + ruleType = "FixedDate", + month = 3, + day = 15, + observanceRule = "None", + }; + HttpResponseMessage updateResp = await Api.PutAsJsonAsync( + $"/api/holiday-calendars/{calId}/rules/{ruleId}", updatedRule, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.OK, updateResp.StatusCode ); + + // Preview again: should now show March 15 instead of January 1 + HttpResponseMessage preview2Resp = await Api.GetAsync( + $"/api/holiday-calendars/{calId}/preview?startYear=2026&endYear=2026", ct ); + Assert.AreEqual( HttpStatusCode.OK, preview2Resp.StatusCode ); + + JsonElement preview2Result = await preview2Resp.Content.ReadFromJsonAsync( JsonOptions, ct ); + JsonElement[] dates2 = [.. preview2Result.GetProperty( "dates" ).EnumerateArray( )]; + Assert.HasCount( 1, dates2 ); + + string dateStr = dates2[0].GetProperty( "date" ).GetString( )!; + Assert.Contains( dateStr, "2026-03-15", + $"After rule update, preview should show March 15, but got: {dateStr}" ); + } +} diff --git a/src/Test/Werkr.Tests/Integration/ScheduleExecutionTests.cs b/src/Test/Werkr.Tests/Integration/ScheduleExecutionTests.cs new file mode 100644 index 0000000..1842957 --- /dev/null +++ b/src/Test/Werkr.Tests/Integration/ScheduleExecutionTests.cs @@ -0,0 +1,247 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace Werkr.Tests.Integration; + +/// +/// Integration tests for the scheduling execution pipeline. +/// Tests exercise the Werkr API (via Testcontainers + WebApplicationFactory) through REST API +/// endpoints, verifying schedule/task/job persistence, occurrence calculation, ad-hoc +/// execution error handling, and schedule invalidation flow. +/// +/// All tests share the instance and use the +/// pre-authenticated API client. +/// +/// +[TestClass] +public class ScheduleExecutionTests { + public TestContext TestContext { get; set; } = null!; + + private static JsonSerializerOptions JsonOptions => AppHostFixture.JsonOptions; + private static HttpClient Api => AppHostFixture.ApiClient; + + #region Test Infrastructure + + private static async Task CreateDailyScheduleAsync( + string name, string date, string time, int dayInterval, + CancellationToken ct ) { + var request = new { + name, + stopTaskAfterMinutes = 60L, + startDateTime = new { date, time, timeZoneId = "UTC" }, + dailyRecurrence = new { dayInterval } + }; + + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/schedules", request, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, response.StatusCode, + $"Schedule creation failed: {await response.Content.ReadAsStringAsync( ct )}" ); + + return await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + } + + private static async Task CreateTaskAsync( + string name, string content, string[] targetTags, + Guid? scheduleId, CancellationToken ct ) { + var request = new { + name, + description = $"Integration test task: {name}", + actionType = "ShellCommand", + content, + targetTags, + enabled = true, + timeoutMinutes = 5L, + scheduleId + }; + + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/tasks", request, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, response.StatusCode, + $"Task creation failed: {await response.Content.ReadAsStringAsync( ct )}" ); + + return await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + } + + #endregion Test Infrastructure + + [TestMethod] + [Timeout( 60_000 )] + public async Task ScheduleCreation_PersistsAndReturnsCorrectData( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement created = await CreateDailyScheduleAsync( + "IntTest_ScheduleCrud", "2026-06-15", "08:30:00", 1, ct ); + + string scheduleId = created.GetProperty( "id" ).GetString( )!; + Assert.IsFalse( string.IsNullOrEmpty( scheduleId ), "Schedule ID should be a non-empty GUID." ); + + HttpResponseMessage getResponse = await Api.GetAsync( $"/api/schedules/{scheduleId}", ct ); + Assert.AreEqual( HttpStatusCode.OK, getResponse.StatusCode ); + + JsonElement retrieved = await getResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( "IntTest_ScheduleCrud", retrieved.GetProperty( "name" ).GetString( ) ); + Assert.AreEqual( 60L, retrieved.GetProperty( "stopTaskAfterMinutes" ).GetInt64( ) ); + + JsonElement startDt = retrieved.GetProperty( "startDateTime" ); + Assert.AreEqual( "2026-06-15", startDt.GetProperty( "date" ).GetString( ) ); + StringAssert.StartsWith( startDt.GetProperty( "time" ).GetString( )!, "08:30" ); + Assert.AreEqual( "UTC", startDt.GetProperty( "timeZoneId" ).GetString( ) ); + + JsonElement daily = retrieved.GetProperty( "dailyRecurrence" ); + Assert.AreEqual( 1, daily.GetProperty( "dayInterval" ).GetInt32( ) ); + + HttpResponseMessage listResponse = await Api.GetAsync( "/api/schedules", ct ); + Assert.AreEqual( HttpStatusCode.OK, listResponse.StatusCode ); + + JsonElement list = await listResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.IsGreaterThanOrEqualTo( 1, list.GetArrayLength( ), + "Schedule list should contain at least one schedule." ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task TaskLinkedToSchedule_OccurrencePreviewReturnsExpectedDates( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement schedule = await CreateDailyScheduleAsync( + "IntTest_OccurrencePreview", "2026-06-15", "08:00:00", 1, ct ); + string scheduleId = schedule.GetProperty( "id" ).GetString( )!; + + JsonElement task = await CreateTaskAsync( + "IntTest_OccurrenceTask", "echo occurrence-test", + ["integration-test"], Guid.Parse( scheduleId ), ct ); + + long taskId = task.GetProperty( "id" ).GetInt64( ); + Assert.IsGreaterThan( 0L, taskId, "Task ID should be a positive integer." ); + Assert.AreEqual( scheduleId, task.GetProperty( "scheduleId" ).GetString( ), + "Task should be linked to the created schedule." ); + + string windowEnd = "2026-06-22T23:59:59Z"; + HttpResponseMessage occResponse = await Api.GetAsync( + $"/api/schedules/{scheduleId}/occurrences?windowEnd={Uri.EscapeDataString( windowEnd )}", ct ); + Assert.AreEqual( HttpStatusCode.OK, occResponse.StatusCode ); + + JsonElement occResult = await occResponse.Content + .ReadFromJsonAsync( JsonOptions, ct ); + JsonElement occurrences = occResult.GetProperty( "occurrences" ); + + Assert.AreEqual( 8, occurrences.GetArrayLength( ), + "Daily schedule (interval=1) from Jun 15 to Jun 22 should produce exactly 8 occurrences." ); + + for (int i = 1; i < occurrences.GetArrayLength( ); i++) { + DateTime prev = DateTime.Parse( occurrences[i - 1].GetString( )! ); + DateTime curr = DateTime.Parse( occurrences[i].GetString( )! ); + double dayDiff = ( curr - prev ).TotalDays; + Assert.AreEqual( 1.0, dayDiff, 0.01, + $"Occurrences at index {i - 1} and {i} should be exactly 1 day apart, but gap was {dayDiff:F2} days." ); + } + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task ScheduleUpdate_PersistsChangesAndTriggersInvalidationPath( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement schedule = await CreateDailyScheduleAsync( + "IntTest_ScheduleUpdate", "2026-06-15", "08:00:00", 1, ct ); + string scheduleId = schedule.GetProperty( "id" ).GetString( )!; + + _ = await CreateTaskAsync( + "IntTest_UpdateLinkedTask", "echo update-test", + ["integration-test"], Guid.Parse( scheduleId ), ct ); + + var updateRequest = new { + name = "IntTest_UpdatedName", + stopTaskAfterMinutes = 120L, + startDateTime = new { date = "2026-07-01", time = "10:00:00", timeZoneId = "UTC" }, + dailyRecurrence = new { dayInterval = 2 } + }; + + HttpResponseMessage putResponse = await Api.PutAsJsonAsync( + $"/api/schedules/{scheduleId}", updateRequest, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.OK, putResponse.StatusCode, + $"Schedule update failed: {await putResponse.Content.ReadAsStringAsync( ct )}" ); + + await Task.Delay( 500, ct ); + + HttpResponseMessage getResponse = await Api.GetAsync( $"/api/schedules/{scheduleId}", ct ); + Assert.AreEqual( HttpStatusCode.OK, getResponse.StatusCode ); + + JsonElement updated = await getResponse.Content + .ReadFromJsonAsync( JsonOptions, ct ); + + Assert.AreEqual( "IntTest_UpdatedName", updated.GetProperty( "name" ).GetString( ) ); + Assert.AreEqual( 120L, updated.GetProperty( "stopTaskAfterMinutes" ).GetInt64( ) ); + + JsonElement daily = updated.GetProperty( "dailyRecurrence" ); + Assert.AreEqual( 2, daily.GetProperty( "dayInterval" ).GetInt32( ) ); + + JsonElement startDt = updated.GetProperty( "startDateTime" ); + Assert.AreEqual( "2026-07-01", startDt.GetProperty( "date" ).GetString( ) ); + StringAssert.StartsWith( startDt.GetProperty( "time" ).GetString( )!, "10:00" ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task ScheduleDelete_RemovesScheduleAndReturnsNotFoundAfter( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement schedule = await CreateDailyScheduleAsync( + "IntTest_ScheduleDelete", "2026-06-15", "12:00:00", 3, ct ); + string scheduleId = schedule.GetProperty( "id" ).GetString( )!; + + HttpResponseMessage existsResponse = await Api.GetAsync( $"/api/schedules/{scheduleId}", ct ); + Assert.AreEqual( HttpStatusCode.OK, existsResponse.StatusCode ); + + HttpResponseMessage deleteResponse = await Api.DeleteAsync( + $"/api/schedules/{scheduleId}", ct ); + Assert.AreEqual( HttpStatusCode.NoContent, deleteResponse.StatusCode ); + + HttpResponseMessage notFoundResponse = await Api.GetAsync( + $"/api/schedules/{scheduleId}", ct ); + Assert.AreEqual( HttpStatusCode.NotFound, notFoundResponse.StatusCode ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task AdHocTaskRun_WithoutConnectedAgent_ReturnsConflict( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement task = await CreateTaskAsync( + "IntTest_AdHocRun", "echo adhoc-test", + ["nonexistent-agent-tag-abc123"], null, ct ); + long taskId = task.GetProperty( "id" ).GetInt64( ); + + HttpResponseMessage runResponse = await Api.PostAsJsonAsync( + $"/api/tasks/{taskId}/run", new object( ), JsonOptions, ct ); + + Assert.AreEqual( HttpStatusCode.Conflict, runResponse.StatusCode, + "Ad-hoc run should return 409 Conflict when no agent matches the target tags." ); + + string body = await runResponse.Content.ReadAsStringAsync( ct ); + StringAssert.Contains( body, "No connected agent", + "Conflict response should describe that no matching agent was found." ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task JobHistory_ForNewTask_ReturnsEmptyList( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement task = await CreateTaskAsync( + "IntTest_JobHistory", "echo jobhistory-test", + ["integration-test"], null, ct ); + long taskId = task.GetProperty( "id" ).GetInt64( ); + + HttpResponseMessage jobsResponse = await Api.GetAsync( + $"/api/tasks/{taskId}/jobs", ct ); + + Assert.AreEqual( HttpStatusCode.OK, jobsResponse.StatusCode ); + + JsonElement jobList = await jobsResponse.Content + .ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( JsonValueKind.Array, jobList.ValueKind ); + Assert.AreEqual( 0, jobList.GetArrayLength( ), + "A newly created task should have no job history." ); + } +} diff --git a/src/Test/Werkr.Tests/Integration/ScheduledActionTests.cs b/src/Test/Werkr.Tests/Integration/ScheduledActionTests.cs new file mode 100644 index 0000000..080e9da --- /dev/null +++ b/src/Test/Werkr.Tests/Integration/ScheduledActionTests.cs @@ -0,0 +1,385 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace Werkr.Tests.Integration; + +/// +/// Integration tests for Action-type tasks linked to schedules. +/// Validates that Action tasks persist correctly with schedules, that scheduled +/// occurrence previews work for Action tasks, and that the schedule-linked +/// Action task round-trips all action-specific fields correctly. +/// All tests share the instance. +/// +[TestClass] +public class ScheduledActionTests { + + public TestContext TestContext { get; set; } = null!; + + private static JsonSerializerOptions JsonOptions => AppHostFixture.JsonOptions; + private static HttpClient Api => AppHostFixture.ApiClient; + + #region Helpers + + private static async Task CreateDailyScheduleAsync( + string name, string date, string time, int dayInterval, + CancellationToken ct ) { + var request = new { + name, + stopTaskAfterMinutes = 60L, + startDateTime = new { date, time, timeZoneId = "UTC" }, + dailyRecurrence = new { dayInterval }, + }; + + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/schedules", request, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, response.StatusCode, + $"Schedule creation failed: {await response.Content.ReadAsStringAsync( ct )}" ); + + return await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + } + + private static async Task CreateActionTaskAsync( + string name, + string actionSubType, + object parameters, + Guid? scheduleId, + CancellationToken ct ) { + + string parametersJson = JsonSerializer.Serialize( parameters, JsonOptions ); + + var request = new { + name, + description = $"Scheduled action test: {name}", + actionType = "Action", + content = "", + targetTags = new[] { "integration-test" }, + enabled = true, + timeoutMinutes = 5L, + scheduleId, + actionSubType, + actionParameters = parametersJson, + }; + + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/tasks", request, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, response.StatusCode, + $"Action task creation failed: {await response.Content.ReadAsStringAsync( ct )}" ); + + return await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + } + + #endregion Helpers + + #region Scheduled Action Task — Create and Link + + [TestMethod] + [Timeout( 60_000 )] + public async Task ActionTaskLinkedToSchedule_PersistsAllFields( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Create a daily schedule + JsonElement schedule = await CreateDailyScheduleAsync( + "IntTest_ActionSchedule", "2026-07-01", "09:00:00", 1, ct ); + string scheduleId = schedule.GetProperty( "id" ).GetString( )!; + + // Create an Action task linked to the schedule + JsonElement task = await CreateActionTaskAsync( + "IntTest_ScheduledCreateFile", + "CreateFile", + new { path = "/data/report.txt", content = "daily report", overwrite = true }, + Guid.Parse( scheduleId ), + ct ); + + long taskId = task.GetProperty( "id" ).GetInt64( ); + Assert.IsGreaterThan( 0L, taskId, "Task ID should be positive." ); + Assert.AreEqual( "Action", task.GetProperty( "actionType" ).GetString( ) ); + Assert.AreEqual( "CreateFile", task.GetProperty( "actionSubType" ).GetString( ) ); + Assert.AreEqual( scheduleId, task.GetProperty( "scheduleId" ).GetString( ), + "Task should be linked to the created schedule." ); + + // Read back and verify all fields survived the round-trip + HttpResponseMessage getResponse = await Api.GetAsync( $"/api/tasks/{taskId}", ct ); + Assert.AreEqual( HttpStatusCode.OK, getResponse.StatusCode ); + + JsonElement retrieved = await getResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( "CreateFile", retrieved.GetProperty( "actionSubType" ).GetString( ) ); + Assert.AreEqual( scheduleId, retrieved.GetProperty( "scheduleId" ).GetString( ) ); + + string? paramsJson = retrieved.GetProperty( "actionParameters" ).GetString( ); + Assert.IsNotNull( paramsJson ); + using JsonDocument parsedParams = JsonDocument.Parse( paramsJson ); + Assert.AreEqual( "/data/report.txt", parsedParams.RootElement.GetProperty( "path" ).GetString( ) ); + Assert.AreEqual( "daily report", parsedParams.RootElement.GetProperty( "content" ).GetString( ) ); + Assert.IsTrue( parsedParams.RootElement.GetProperty( "overwrite" ).GetBoolean( ) ); + + // Cleanup + _ = await Api.DeleteAsync( $"/api/tasks/{taskId}", ct ); + _ = await Api.DeleteAsync( $"/api/schedules/{scheduleId}", ct ); + } + + #endregion Scheduled Action Task — Create and Link + + #region Scheduled Action Task — Occurrence Preview + + [TestMethod] + [Timeout( 60_000 )] + public async Task ScheduledActionTask_OccurrencePreview_ReturnsExpectedDates( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Daily schedule starting Jun 15 + JsonElement schedule = await CreateDailyScheduleAsync( + "IntTest_ActionOccurrence", "2026-06-15", "08:00:00", 2, ct ); + string scheduleId = schedule.GetProperty( "id" ).GetString( )!; + + // Link an Action task + _ = await CreateActionTaskAsync( + "IntTest_ActionOccurrenceTask", + "CopyFile", + new { source = "/data/src", destination = "/data/dst" }, + Guid.Parse( scheduleId ), + ct ); + + // Query occurrence preview for a 10-day window + string windowEnd = "2026-06-25T23:59:59Z"; + HttpResponseMessage occResponse = await Api.GetAsync( + $"/api/schedules/{scheduleId}/occurrences?windowEnd={Uri.EscapeDataString( windowEnd )}", ct ); + Assert.AreEqual( HttpStatusCode.OK, occResponse.StatusCode ); + + JsonElement occResult = await occResponse.Content + .ReadFromJsonAsync( JsonOptions, ct ); + JsonElement occurrences = occResult.GetProperty( "occurrences" ); + + // Every 2 days from Jun 15 to Jun 25 → Jun 15, 17, 19, 21, 23, 25 = 6 occurrences + Assert.AreEqual( 6, occurrences.GetArrayLength( ), + "Daily schedule (interval=2) from Jun 15 to Jun 25 should produce exactly 6 occurrences." ); + + // Verify spacing + for (int i = 1; i < occurrences.GetArrayLength( ); i++) { + DateTime prev = DateTime.Parse( occurrences[i - 1].GetString( )! ); + DateTime curr = DateTime.Parse( occurrences[i].GetString( )! ); + double dayDiff = ( curr - prev ).TotalDays; + Assert.AreEqual( 2.0, dayDiff, 0.01, + $"Occurrences at index {i - 1} and {i} should be exactly 2 days apart, but gap was {dayDiff:F2} days." ); + } + + // Cleanup + _ = await Api.DeleteAsync( $"/api/schedules/{scheduleId}", ct ); + } + + #endregion Scheduled Action Task — Occurrence Preview + + #region Scheduled Action Task — Update Action Fields + + [TestMethod] + [Timeout( 60_000 )] + public async Task ScheduledActionTask_UpdateActionType_PersistsChanges( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement schedule = await CreateDailyScheduleAsync( + "IntTest_ActionUpdate", "2026-08-01", "10:00:00", 1, ct ); + string scheduleId = schedule.GetProperty( "id" ).GetString( )!; + + JsonElement task = await CreateActionTaskAsync( + "IntTest_ScheduledActionUpdate", + "CreateFile", + new { path = "/data/original.txt" }, + Guid.Parse( scheduleId ), + ct ); + + long taskId = task.GetProperty( "id" ).GetInt64( ); + + // Update the task to a different action type while keeping the schedule link + string newParams = JsonSerializer.Serialize( + new { path = "/data/original.txt", content = "appended data", append = true }, + JsonOptions ); + + var updateRequest = new { + name = "IntTest_ScheduledActionUpdate_v2", + description = "Updated to WriteContent", + actionType = "Action", + content = "", + targetTags = new[] { "integration-test" }, + enabled = true, + timeoutMinutes = 10L, + scheduleId = Guid.Parse( scheduleId ), + actionSubType = "WriteContent", + actionParameters = newParams, + }; + + HttpResponseMessage putResponse = await Api.PutAsJsonAsync( + $"/api/tasks/{taskId}", updateRequest, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.OK, putResponse.StatusCode, + $"Task update failed: {await putResponse.Content.ReadAsStringAsync( ct )}" ); + + JsonElement updated = await putResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( "WriteContent", updated.GetProperty( "actionSubType" ).GetString( ) ); + Assert.AreEqual( scheduleId, updated.GetProperty( "scheduleId" ).GetString( ), + "Schedule link should be preserved after update." ); + + // Verify parameters updated + string? paramsJson = updated.GetProperty( "actionParameters" ).GetString( ); + Assert.IsNotNull( paramsJson ); + using JsonDocument parsedParams = JsonDocument.Parse( paramsJson ); + Assert.IsTrue( parsedParams.RootElement.GetProperty( "append" ).GetBoolean( ) ); + + // Cleanup + _ = await Api.DeleteAsync( $"/api/tasks/{taskId}", ct ); + _ = await Api.DeleteAsync( $"/api/schedules/{scheduleId}", ct ); + } + + #endregion Scheduled Action Task — Update Action Fields + + #region Multiple Action Tasks on Same Schedule + + [TestMethod] + [Timeout( 60_000 )] + public async Task MultipleActionTasks_SameSchedule_AllPersistCorrectly( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement schedule = await CreateDailyScheduleAsync( + "IntTest_MultiAction", "2026-09-01", "06:00:00", 1, ct ); + string scheduleId = schedule.GetProperty( "id" ).GetString( )!; + Guid scheduleGuid = Guid.Parse( scheduleId ); + + // Create three different action tasks on the same schedule + JsonElement task1 = await CreateActionTaskAsync( + "IntTest_MultiAction_CreateDir", + "CreateDirectory", + new { path = "/data/daily-output" }, + scheduleGuid, ct ); + + JsonElement task2 = await CreateActionTaskAsync( + "IntTest_MultiAction_CreateFile", + "CreateFile", + new { path = "/data/daily-output/report.csv", content = "header1,header2" }, + scheduleGuid, ct ); + + JsonElement task3 = await CreateActionTaskAsync( + "IntTest_MultiAction_CopyBackup", + "CopyFile", + new { source = "/data/daily-output/report.csv", destination = "/backup/report.csv", overwrite = true }, + scheduleGuid, ct ); + + long task1Id = task1.GetProperty( "id" ).GetInt64( ); + long task2Id = task2.GetProperty( "id" ).GetInt64( ); + long task3Id = task3.GetProperty( "id" ).GetInt64( ); + + // Verify each task has correct action type and schedule link + Assert.AreEqual( "CreateDirectory", task1.GetProperty( "actionSubType" ).GetString( ) ); + Assert.AreEqual( "CreateFile", task2.GetProperty( "actionSubType" ).GetString( ) ); + Assert.AreEqual( "CopyFile", task3.GetProperty( "actionSubType" ).GetString( ) ); + + Assert.AreEqual( scheduleId, task1.GetProperty( "scheduleId" ).GetString( ) ); + Assert.AreEqual( scheduleId, task2.GetProperty( "scheduleId" ).GetString( ) ); + Assert.AreEqual( scheduleId, task3.GetProperty( "scheduleId" ).GetString( ) ); + + // Cleanup + _ = await Api.DeleteAsync( $"/api/tasks/{task1Id}", ct ); + _ = await Api.DeleteAsync( $"/api/tasks/{task2Id}", ct ); + _ = await Api.DeleteAsync( $"/api/tasks/{task3Id}", ct ); + _ = await Api.DeleteAsync( $"/api/schedules/{scheduleId}", ct ); + } + + #endregion Multiple Action Tasks on Same Schedule + + #region Scheduled Action Task — Unlink Schedule + + [TestMethod] + [Timeout( 60_000 )] + public async Task ScheduledActionTask_RemoveScheduleLink_TaskRemains( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement schedule = await CreateDailyScheduleAsync( + "IntTest_UnlinkSchedule", "2026-10-01", "12:00:00", 1, ct ); + string scheduleId = schedule.GetProperty( "id" ).GetString( )!; + + JsonElement task = await CreateActionTaskAsync( + "IntTest_UnlinkAction", + "StartProcess", + new { fileName = "echo", arguments = "scheduled-run", waitForExit = true }, + Guid.Parse( scheduleId ), + ct ); + + long taskId = task.GetProperty( "id" ).GetInt64( ); + Assert.AreEqual( scheduleId, task.GetProperty( "scheduleId" ).GetString( ) ); + + // Update task to remove the schedule link + string paramsJson = JsonSerializer.Serialize( + new { fileName = "echo", arguments = "manual-run", waitForExit = true }, + JsonOptions ); + + var updateRequest = new { + name = "IntTest_UnlinkAction", + description = "No longer scheduled", + actionType = "Action", + content = "", + targetTags = new[] { "integration-test" }, + enabled = true, + timeoutMinutes = 5L, + scheduleId = (Guid?) null, + actionSubType = "StartProcess", + actionParameters = paramsJson, + }; + + HttpResponseMessage putResponse = await Api.PutAsJsonAsync( + $"/api/tasks/{taskId}", updateRequest, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.OK, putResponse.StatusCode, + $"Task update failed: {await putResponse.Content.ReadAsStringAsync( ct )}" ); + + JsonElement updated = await putResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + + // Schedule link should be removed + Assert.AreEqual( JsonValueKind.Null, updated.GetProperty( "scheduleId" ).ValueKind, + "Schedule link should be null after unlinking." ); + + // Action fields should be preserved + Assert.AreEqual( "StartProcess", updated.GetProperty( "actionSubType" ).GetString( ) ); + Assert.AreEqual( "Action", updated.GetProperty( "actionType" ).GetString( ) ); + + // Cleanup + _ = await Api.DeleteAsync( $"/api/tasks/{taskId}", ct ); + _ = await Api.DeleteAsync( $"/api/schedules/{scheduleId}", ct ); + } + + #endregion Scheduled Action Task — Unlink Schedule + + #region Schedule Deletion — Action Tasks Persist + + [TestMethod] + [Timeout( 60_000 )] + public async Task DeleteSchedule_ActionTasksRetainActionFields( ) { + CancellationToken ct = TestContext.CancellationToken; + + JsonElement schedule = await CreateDailyScheduleAsync( + "IntTest_DeleteScheduleRetain", "2026-11-01", "08:00:00", 1, ct ); + string scheduleId = schedule.GetProperty( "id" ).GetString( )!; + + JsonElement task = await CreateActionTaskAsync( + "IntTest_RetainedAction", + "DeleteFile", + new { path = "/tmp/old-logs.txt", recursive = false, force = true }, + Guid.Parse( scheduleId ), + ct ); + + long taskId = task.GetProperty( "id" ).GetInt64( ); + + // Delete the schedule + HttpResponseMessage deleteResponse = await Api.DeleteAsync( $"/api/schedules/{scheduleId}", ct ); + Assert.AreEqual( HttpStatusCode.NoContent, deleteResponse.StatusCode ); + + // Task should still exist with action fields intact + HttpResponseMessage getResponse = await Api.GetAsync( $"/api/tasks/{taskId}", ct ); + Assert.AreEqual( HttpStatusCode.OK, getResponse.StatusCode ); + + JsonElement retrieved = await getResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( "Action", retrieved.GetProperty( "actionType" ).GetString( ) ); + Assert.AreEqual( "DeleteFile", retrieved.GetProperty( "actionSubType" ).GetString( ) ); + Assert.IsNotNull( retrieved.GetProperty( "actionParameters" ).GetString( ), + "ActionParameters should be preserved after schedule deletion." ); + + // Cleanup + _ = await Api.DeleteAsync( $"/api/tasks/{taskId}", ct ); + } + + #endregion Schedule Deletion — Action Tasks Persist +} diff --git a/src/Test/Werkr.Tests/Integration/WorkflowIntegrationTests.cs b/src/Test/Werkr.Tests/Integration/WorkflowIntegrationTests.cs new file mode 100644 index 0000000..ba98d87 --- /dev/null +++ b/src/Test/Werkr.Tests/Integration/WorkflowIntegrationTests.cs @@ -0,0 +1,371 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace Werkr.Tests.Integration; + +/// +/// Integration tests for workflow CRUD, workflow steps, step dependencies, +/// task CRUD with tags, job query endpoints, schedule recurrence types, +/// and agent/diagnostics health endpoints. +/// All tests share the instance. +/// +[TestClass] +public class WorkflowIntegrationTests { + public TestContext TestContext { get; set; } = null!; + + private static JsonSerializerOptions JsonOptions => AppHostFixture.JsonOptions; + private static HttpClient Api => AppHostFixture.ApiClient; + + #region Workflow CRUD + + [TestMethod] + [Timeout( 60_000 )] + public async Task WorkflowCrud_CreateReadUpdateDelete( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Create + var createRequest = new { + name = "IntTest_Workflow", + description = "Integration test workflow", + enabled = true, + scheduleId = (string?) null + }; + + HttpResponseMessage createResponse = await Api.PostAsJsonAsync( + "/api/workflows", createRequest, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, createResponse.StatusCode, + $"Workflow creation failed: {await createResponse.Content.ReadAsStringAsync( ct )}" ); + + JsonElement created = await createResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + long workflowId = created.GetProperty( "id" ).GetInt64( ); + Assert.IsGreaterThan( 0L, workflowId, "Workflow ID should be positive." ); + Assert.AreEqual( "IntTest_Workflow", created.GetProperty( "name" ).GetString( ) ); + + // Read + HttpResponseMessage getResponse = await Api.GetAsync( $"/api/workflows/{workflowId}", ct ); + Assert.AreEqual( HttpStatusCode.OK, getResponse.StatusCode ); + + JsonElement retrieved = await getResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( "IntTest_Workflow", retrieved.GetProperty( "name" ).GetString( ) ); + Assert.AreEqual( "Integration test workflow", retrieved.GetProperty( "description" ).GetString( ) ); + + // Update + var updateRequest = new { + name = "IntTest_Workflow_Updated", + description = "Updated description", + enabled = false, + scheduleId = (string?) null + }; + + HttpResponseMessage putResponse = await Api.PutAsJsonAsync( + $"/api/workflows/{workflowId}", updateRequest, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.OK, putResponse.StatusCode ); + + JsonElement updated = await putResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( "IntTest_Workflow_Updated", updated.GetProperty( "name" ).GetString( ) ); + + // List + HttpResponseMessage listResponse = await Api.GetAsync( "/api/workflows", ct ); + Assert.AreEqual( HttpStatusCode.OK, listResponse.StatusCode ); + + JsonElement list = await listResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.IsGreaterThanOrEqualTo( 1, list.GetArrayLength( ), + "Workflow list should contain at least one workflow." ); + + // Delete + HttpResponseMessage deleteResponse = await Api.DeleteAsync( $"/api/workflows/{workflowId}", ct ); + Assert.AreEqual( HttpStatusCode.NoContent, deleteResponse.StatusCode ); + + HttpResponseMessage notFoundResponse = await Api.GetAsync( $"/api/workflows/{workflowId}", ct ); + Assert.AreEqual( HttpStatusCode.NotFound, notFoundResponse.StatusCode ); + } + + #endregion Workflow CRUD + + #region Workflow Steps & Dependencies + + [TestMethod] + [Timeout( 60_000 )] + public async Task WorkflowSteps_AddAndLinkWithDependency( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Create a workflow + var wfRequest = new { + name = "IntTest_StepDeps", + description = "Workflow for step dependency test", + enabled = true, + scheduleId = (string?) null + }; + + HttpResponseMessage wfResponse = await Api.PostAsJsonAsync( + "/api/workflows", wfRequest, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, wfResponse.StatusCode, + $"Workflow creation failed: {await wfResponse.Content.ReadAsStringAsync( ct )}" ); + + JsonElement wf = await wfResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + long workflowId = wf.GetProperty( "id" ).GetInt64( ); + + // Create a task for step 1 + var task1Request = new { + name = "IntTest_Step1_Task", + description = "First step task", + actionType = "ShellCommand", + content = "echo step1", + targetTags = new[] { "integration-test" }, + enabled = true, + timeoutMinutes = 5L + }; + + HttpResponseMessage task1Response = await Api.PostAsJsonAsync( + "/api/tasks", task1Request, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, task1Response.StatusCode ); + + JsonElement task1 = await task1Response.Content.ReadFromJsonAsync( JsonOptions, ct ); + long task1Id = task1.GetProperty( "id" ).GetInt64( ); + + // Create a task for step 2 + var task2Request = new { + name = "IntTest_Step2_Task", + description = "Second step task", + actionType = "ShellCommand", + content = "echo step2", + targetTags = new[] { "integration-test" }, + enabled = true, + timeoutMinutes = 5L + }; + + HttpResponseMessage task2Response = await Api.PostAsJsonAsync( + "/api/tasks", task2Request, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, task2Response.StatusCode ); + + JsonElement task2 = await task2Response.Content.ReadFromJsonAsync( JsonOptions, ct ); + long task2Id = task2.GetProperty( "id" ).GetInt64( ); + + // Add step 1 + var step1Request = new { + taskId = task1Id, + order = 1, + controlStatement = "Sequential", + dependencyMode = "All" + }; + + HttpResponseMessage step1Response = await Api.PostAsJsonAsync( + $"/api/workflows/{workflowId}/steps", step1Request, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, step1Response.StatusCode, + $"Step 1 creation failed: {await step1Response.Content.ReadAsStringAsync( ct )}" ); + + JsonElement step1 = await step1Response.Content.ReadFromJsonAsync( JsonOptions, ct ); + long step1Id = step1.GetProperty( "id" ).GetInt64( ); + + // Add step 2 + var step2Request = new { + taskId = task2Id, + order = 2, + controlStatement = "Sequential", + dependencyMode = "All" + }; + + HttpResponseMessage step2Response = await Api.PostAsJsonAsync( + $"/api/workflows/{workflowId}/steps", step2Request, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, step2Response.StatusCode, + $"Step 2 creation failed: {await step2Response.Content.ReadAsStringAsync( ct )}" ); + + JsonElement step2 = await step2Response.Content.ReadFromJsonAsync( JsonOptions, ct ); + long step2Id = step2.GetProperty( "id" ).GetInt64( ); + + // Add dependency: step 2 depends on step 1 + var depRequest = new { dependsOnStepId = step1Id }; + + HttpResponseMessage depResponse = await Api.PostAsJsonAsync( + $"/api/workflows/{workflowId}/steps/{step2Id}/dependencies", depRequest, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, depResponse.StatusCode, + $"Dependency creation failed: {await depResponse.Content.ReadAsStringAsync( ct )}" ); + + // Verify the workflow now has 2 steps with the dependency + HttpResponseMessage getWfResponse = await Api.GetAsync( $"/api/workflows/{workflowId}", ct ); + Assert.AreEqual( HttpStatusCode.OK, getWfResponse.StatusCode ); + + JsonElement fullWorkflow = await getWfResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + JsonElement steps = fullWorkflow.GetProperty( "steps" ); + Assert.AreEqual( 2, steps.GetArrayLength( ), "Workflow should have exactly 2 steps." ); + + // Cleanup + _ = await Api.DeleteAsync( $"/api/workflows/{workflowId}", ct ); + } + + #endregion Workflow Steps & Dependencies + + #region Task CRUD with Tags + + [TestMethod] + [Timeout( 60_000 )] + public async Task TaskCrud_CreateWithTagsUpdateAndDelete( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Create task with tags + var createRequest = new { + name = "IntTest_TaskTags", + description = "Task with multiple tags", + actionType = "ShellCommand", + content = "echo tagged-task", + targetTags = new[] { "tag-a", "tag-b", "integration-test" }, + enabled = true, + timeoutMinutes = 10L + }; + + HttpResponseMessage createResponse = await Api.PostAsJsonAsync( + "/api/tasks", createRequest, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, createResponse.StatusCode, + $"Task creation failed: {await createResponse.Content.ReadAsStringAsync( ct )}" ); + + JsonElement created = await createResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + long taskId = created.GetProperty( "id" ).GetInt64( ); + Assert.IsGreaterThan( 0L, taskId ); + + // Verify tags persisted + JsonElement tags = created.GetProperty( "targetTags" ); + Assert.AreEqual( 3, tags.GetArrayLength( ), "Task should have 3 target tags." ); + + // Update task + var updateRequest = new { + name = "IntTest_TaskTags_Updated", + description = "Updated task", + actionType = "ShellCommand", + content = "echo updated-task", + targetTags = new[] { "tag-c" }, + enabled = false, + timeoutMinutes = 15L + }; + + HttpResponseMessage putResponse = await Api.PutAsJsonAsync( + $"/api/tasks/{taskId}", updateRequest, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.OK, putResponse.StatusCode ); + + JsonElement updated = await putResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( "IntTest_TaskTags_Updated", updated.GetProperty( "name" ).GetString( ) ); + + // Verify tags updated + JsonElement updatedTags = updated.GetProperty( "targetTags" ); + Assert.AreEqual( 1, updatedTags.GetArrayLength( ), "Updated task should have 1 target tag." ); + + // Delete + HttpResponseMessage deleteResponse = await Api.DeleteAsync( $"/api/tasks/{taskId}", ct ); + Assert.AreEqual( HttpStatusCode.NoContent, deleteResponse.StatusCode ); + + HttpResponseMessage notFoundResponse = await Api.GetAsync( $"/api/tasks/{taskId}", ct ); + Assert.AreEqual( HttpStatusCode.NotFound, notFoundResponse.StatusCode ); + } + + #endregion Task CRUD with Tags + + #region Job Query Endpoints + + [TestMethod] + [Timeout( 60_000 )] + public async Task JobListEndpoint_ReturnsFilterableResults( ) { + CancellationToken ct = TestContext.CancellationToken; + + HttpResponseMessage response = await Api.GetAsync( "/api/jobs", ct ); + Assert.AreEqual( HttpStatusCode.OK, response.StatusCode ); + + JsonElement jobs = await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( JsonValueKind.Array, jobs.ValueKind, + "Job list should return a JSON array." ); + + // Test with date filter (should still return OK even if no jobs match) + HttpResponseMessage filteredResponse = await Api.GetAsync( + "/api/jobs?since=2020-01-01T00:00:00Z&until=2020-01-02T00:00:00Z", ct ); + Assert.AreEqual( HttpStatusCode.OK, filteredResponse.StatusCode ); + + JsonElement filtered = await filteredResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( 0, filtered.GetArrayLength( ), + "No jobs should exist in the 2020 date range." ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task GetJobById_ForNonExistentJob_ReturnsNotFound( ) { + CancellationToken ct = TestContext.CancellationToken; + + Guid fakeJobId = Guid.NewGuid( ); + HttpResponseMessage response = await Api.GetAsync( $"/api/jobs/{fakeJobId}", ct ); + Assert.AreEqual( HttpStatusCode.NotFound, response.StatusCode ); + } + + #endregion Job Query Endpoints + + #region Schedule Recurrence Types + + [TestMethod] + [Timeout( 60_000 )] + public async Task WeeklySchedule_CreatesAndReturnsCorrectRecurrence( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Monday=1, Wednesday=4, Friday=16 → flags value = 21 + var request = new { + name = "IntTest_WeeklySchedule", + stopTaskAfterMinutes = 30L, + startDateTime = new { date = "2026-06-15", time = "09:00:00", timeZoneId = "UTC" }, + weeklyRecurrence = new { + weekInterval = 1, + daysOfWeek = 1 | 4 | 16 // Monday | Wednesday | Friday + } + }; + + HttpResponseMessage response = await Api.PostAsJsonAsync( + "/api/schedules", request, JsonOptions, ct ); + Assert.AreEqual( HttpStatusCode.Created, response.StatusCode, + $"Weekly schedule creation failed: {await response.Content.ReadAsStringAsync( ct )}" ); + + JsonElement created = await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + string scheduleId = created.GetProperty( "id" ).GetString( )!; + Assert.IsFalse( string.IsNullOrEmpty( scheduleId ) ); + + // Verify weekly recurrence persisted + HttpResponseMessage getResponse = await Api.GetAsync( $"/api/schedules/{scheduleId}", ct ); + Assert.AreEqual( HttpStatusCode.OK, getResponse.StatusCode ); + + JsonElement retrieved = await getResponse.Content.ReadFromJsonAsync( JsonOptions, ct ); + JsonElement weekly = retrieved.GetProperty( "weeklyRecurrence" ); + Assert.AreEqual( 1, weekly.GetProperty( "weekInterval" ).GetInt32( ) ); + + int days = weekly.GetProperty( "daysOfWeek" ).GetInt32( ); + Assert.AreEqual( 1 | 4 | 16, days, "DaysOfWeek flags should be Monday|Wednesday|Friday (21)." ); + + // Cleanup + _ = await Api.DeleteAsync( $"/api/schedules/{scheduleId}", ct ); + } + + #endregion Schedule Recurrence Types + + #region Agent Health & Diagnostics + + [TestMethod] + [Timeout( 60_000 )] + public async Task AgentHealthEndpoint_ReturnsSuccessfully( ) { + CancellationToken ct = TestContext.CancellationToken; + + HttpResponseMessage response = await Api.GetAsync( "/api/agents/health", ct ); + Assert.AreEqual( HttpStatusCode.OK, response.StatusCode ); + + JsonElement health = await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( JsonValueKind.Array, health.ValueKind, + "Agent health should return a JSON array." ); + } + + [TestMethod] + [Timeout( 60_000 )] + public async Task DiagnosticsHealthEndpoint_ReturnsDatabaseStatus( ) { + CancellationToken ct = TestContext.CancellationToken; + + HttpResponseMessage response = await Api.GetAsync( "/api/diagnostics/health", ct ); + Assert.AreEqual( HttpStatusCode.OK, response.StatusCode ); + + JsonElement diagnostics = await response.Content.ReadFromJsonAsync( JsonOptions, ct ); + Assert.AreEqual( JsonValueKind.Array, diagnostics.ValueKind ); + Assert.IsGreaterThanOrEqualTo( 1, diagnostics.GetArrayLength( ), + "Should return at least one database health entry." ); + } + + #endregion Agent Health & Diagnostics +} diff --git a/src/Test/Werkr.Tests/ScheduleIntegrationTests.cs b/src/Test/Werkr.Tests/ScheduleIntegrationTests.cs new file mode 100644 index 0000000..13204db --- /dev/null +++ b/src/Test/Werkr.Tests/ScheduleIntegrationTests.cs @@ -0,0 +1,23 @@ +namespace Werkr.Tests; + +/// +/// Basic health-check tests for the API service hosted via Testcontainers + WebApplicationFactory. +/// Verifies that the service starts successfully and responds on its root endpoint. +/// +[TestClass] +public class ScheduleIntegrationTests { + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + [Timeout( 30_000 )] + public async Task ApiServiceReturnsHealthy_WithScheduleGrpcMapped( ) { + CancellationToken ct = TestContext.CancellationToken; + HttpClient httpClient = AppHostFixture.ApiClient; + + HttpResponseMessage response = await httpClient.GetAsync( "/", ct ); + + Assert.AreEqual( HttpStatusCode.OK, response.StatusCode ); + string content = await response.Content.ReadAsStringAsync( ct ); + Assert.IsTrue( content.Contains( "Werkr API", StringComparison.OrdinalIgnoreCase ) ); + } +} diff --git a/src/Test/Werkr.Tests/WebTests.cs b/src/Test/Werkr.Tests/WebTests.cs new file mode 100644 index 0000000..38a0c71 --- /dev/null +++ b/src/Test/Werkr.Tests/WebTests.cs @@ -0,0 +1,25 @@ +namespace Werkr.Tests; + +/// +/// Basic connectivity test for the API hosted via Testcontainers + WebApplicationFactory. +/// All tests share the instance. +/// +[TestClass] +public class WebTests { + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + [Timeout( 30_000 )] + public async Task GetApiRootReturnsOkStatusCode( ) { + CancellationToken ct = TestContext.CancellationToken; + HttpClient httpClient = AppHostFixture.ApiClient; + HttpResponseMessage response = await httpClient.GetAsync( "/", ct ); + + Assert.AreEqual( HttpStatusCode.OK, response.StatusCode ); + + string content = await response.Content.ReadAsStringAsync( ct ); + Assert.IsTrue( + content.Contains( "Werkr API", StringComparison.OrdinalIgnoreCase ), + "API root should return a response containing 'Werkr API'." ); + } +} diff --git a/src/Test/Werkr.Tests/Werkr.Tests.csproj b/src/Test/Werkr.Tests/Werkr.Tests.csproj new file mode 100644 index 0000000..838c4ec --- /dev/null +++ b/src/Test/Werkr.Tests/Werkr.Tests.csproj @@ -0,0 +1,36 @@ + + + + Exe + false + true + true + false + --settings $(MSBuildProjectDirectory)/Werkr.Tests.runsettings + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Test/Werkr.Tests/Werkr.Tests.runsettings b/src/Test/Werkr.Tests/Werkr.Tests.runsettings new file mode 100644 index 0000000..d39e75d --- /dev/null +++ b/src/Test/Werkr.Tests/Werkr.Tests.runsettings @@ -0,0 +1,14 @@ + + + + + + 1 + ClassLevel + + + + + 300000 + + diff --git a/src/Test/Werkr.Tests/packages.lock.json b/src/Test/Werkr.Tests/packages.lock.json new file mode 100644 index 0000000..bf4acc9 --- /dev/null +++ b/src/Test/Werkr.Tests/packages.lock.json @@ -0,0 +1,1268 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.AspNetCore.Mvc.Testing": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "X5/O3EM4W5IgwaIsTsUgX0mg9dJMN75F6sboZKaYVi46l7M1u8o7eC5ZqlQo1+y7Ertq+XWaSzcv9W8kArTsbQ==", + "dependencies": { + "Microsoft.AspNetCore.TestHost": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Hosting": "10.0.3" + } + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Direct", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "MSTest": { + "type": "Direct", + "requested": "[4.1.0, )", + "resolved": "4.1.0", + "contentHash": "2bk47yg7HcHRyf6Zf0XgCZicTVTQj4D5lonYTO7lWMxCQB+x66VrQNc2dADBfzthKXfHaA37m8i+VV5h6SbWiA==", + "dependencies": { + "MSTest.TestAdapter": "4.1.0", + "MSTest.TestFramework": "4.1.0", + "Microsoft.NET.Test.Sdk": "18.0.1", + "Microsoft.Testing.Extensions.CodeCoverage": "18.4.1", + "Microsoft.Testing.Extensions.TrxReport": "2.1.0" + } + }, + "Testcontainers.PostgreSql": { + "type": "Direct", + "requested": "[4.10.0, )", + "resolved": "4.10.0", + "contentHash": "TP7j3N014O9MONT21lqZPzlVuP1LJYhkRKYZPbRdHl3VN+4RPk5Jt799WvLfxDsOFLVNibNO3B7tP1vcYQmXHA==", + "dependencies": { + "Testcontainers": "4.10.0" + } + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "Docker.DotNet.Enhanced": { + "type": "Transitive", + "resolved": "3.131.1", + "contentHash": "hGLHCNUsQbT2Ab/HUznRnNqYZQs40zInXa3eLwYjeNyfUYbw1pqqDGqcOLl5uGepS8IuigEYakEdAcVT/2ezYg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Docker.DotNet.Enhanced.X509": { + "type": "Transitive", + "resolved": "3.131.1", + "contentHash": "8FU7zmttFQzp0xb0EPupxQ0nGtC2cTpukgh3jMxMT8luj5TSDyzIKTnroDpXCjpg9P2fV+6JIvC+IetsMEfyBA==", + "dependencies": { + "Docker.DotNet.Enhanced": "3.131.1" + } + }, + "Grpc.AspNetCore.Server": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0" + } + }, + "Grpc.AspNetCore.Server.ClientFactory": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==", + "dependencies": { + "Grpc.AspNetCore.Server": "2.76.0", + "Grpc.Net.ClientFactory": "2.76.0" + } + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==", + "dependencies": { + "Grpc.Core.Api": "2.76.0" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "STkCfgCECt2cAekgBpXxFDefH5wd4ytYZKihIZSmQqY92BP8N9qN71qFyRpry8Sl/qT5A+bpwe8v7sjDtg5LEA==" + }, + "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c8GgMKpnNf8fUOKXaZXKV5XaLSlvAts8ICvcPr5CIfjHEWJtbq+URIfBGYesyhnOlWAiSgVsdCBZxMEJIHgfLw==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.3" + } + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + }, + "Microsoft.AspNetCore.TestHost": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "yFTm1tyLkaxmap7egZcOoCxIDviDLbiLraIFz0e4BMHUkXLnpOpPhW66rAGFuUeahmY5JPJdaUTqyCJZMy+05Q==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "O+utSr97NAJowIQT/OVp3Lh9QgW/wALVTP4RG1m2AfFP4IyJmJz0ZBmFJUsRQiAPgq6IRC0t8AAzsiPIsaUDEA==" + }, + "Microsoft.DiaSymReader": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "QcZrCETsBJqy/vQpFtJc+jSXQ0K5sucQ6NUFbTNVHD4vfZZOwjZ/3sBzczkC4DityhD3AVO/+K/+9ioLs1AgRA==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "qVytXuCHQCIJcOsJJnp+1mNCAtiWuLqI0qhCcByFuyxDifthefEWX3fVAXbaxn1lDP2iR1KH44Oq7tvmT7dBHg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "jBm6bpc5OM2VHM/QYVUyD78xweFzble6UsIt7GUnQAwCm07hktFaUBtRfO7viLGg5qPbc4ByteNB7DeVAYNSfA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "f6FiscfqlLrNUR2x7XcMqMGz5ZFRwTvezZuebIn4v2ste0nL/sEZ7pdveDXqDyonVv/QTKT8vvIEqTQCczzsOg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Json": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Features": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "djFt1Jt+2uREWWVQiiA4ilYBDtHHY7nK08c5K8xBD9+XFNw3KDVprylrMkH08bZGK3ZHRAkS7JDV9srfLrcm/g==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Embedded": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "kw/xPl7m4Gv6bqx2ojihTtWiN2K2AklyMIrvncuSi2MOdwu0oMKoyh0G3p2Brt7m43Q9ER0IaA2G4EGjfgDh/w==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + }, + "Microsoft.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "ssbRCALcbBXfQPXLr4bZ3FVlbnDzeR0F6hPKDUCZLPQul7oBeSGaJq+XLUjwYaptOs4TN5cSy1q6LVLPWFgjKQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.3", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.Configuration.Json": "10.0.3", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.3", + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Configuration": "10.0.3", + "Microsoft.Extensions.Logging.Console": "10.0.3", + "Microsoft.Extensions.Logging.Debug": "10.0.3", + "Microsoft.Extensions.Logging.EventLog": "10.0.3", + "Microsoft.Extensions.Logging.EventSource": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "M5gWob3dtzlF14oto1lR1ZuSJrR0gGc+obv7zY9LGmX5y3Ndpve29MrrjqJW/m4CFud4TE/KFUuHjjtwxhCO8g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.Telemetry": "10.3.0" + } + }, + "Microsoft.Extensions.Identity.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GdhTmz+BiVEdsFCT7Vqjhlx8q7j7kGPLinJjudPLO48DxZjSIwh9KlOd/AYJoGR21NjkkHiWijcB3RG7rIfMqw==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Identity.Stores": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "XWu+Xg0dc0VKJxW7iTuhpnSD2jqZ4Kcdr7f3vUf7LOmPkawBLGkUuUA3rl+QQCbXAGnomV/I9T2wTxe1BKkVEA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.Identity.Core": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PBlaoYeusaxNYyN4WFjzcXWlUDSvLUPxy/e6oP1SONOOYA/oBWT2uBmFGJMV9VTtXiXXxCB39LqlYWbsWE4UKA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "7sRvbBH3icaV9qil8fyBKmR+yEZ0yDU6Bq/KgBwswS36164yGaxbf7Kd4hD1iHZ2GfvyoJWWqBUBm9QX/IASAQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Configuration": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "1V1oRR+0DKyetPKI4POn7+jXH4kI1D69R/Rjje/4/BSkTM2iUCsRkr7Q0gDyXayhCXgPEf/P19kgwN5t0s/p8w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "System.Diagnostics.EventLog": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "r2hIVkSrb+f8FqcguHqlzyQ8lNGCtWsOPY9+OzJinrqdzQfszS8fXkHVcNHW4uK6WFxI2tPSiGdms2SeRJq8hg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "dQKlVXzqflsv5X8iDlAN5YmTL1GcLCrOLKo1s9PNdfjqxeu0S/jmWTfiLGno+8+o1qFL3+VFAH5/ftmypN+sPw==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.ServiceDiscovery.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Features": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.0.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "8.0.1" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "WNpu6vI2rA0pXY4r7NKxCN16XRWl5uHu6qjuyVLoDo6oYEggIQefrMjkRuibQHm/NslIUNCcKftvoWAN80MSAg==", + "dependencies": { + "Microsoft.CodeCoverage": "18.0.1", + "Microsoft.TestPlatform.TestHost": "18.0.1" + } + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Microsoft.Testing.Extensions.CodeCoverage": { + "type": "Transitive", + "resolved": "18.4.1", + "contentHash": "l1VZM9dg9s76L5D288ipAT4HRYDJ6Vxh8wX20gfS9VnpueedRfN4/aGNn4oA1g6pwq2WSM3Ci7IoSSGPiqu+WQ==", + "dependencies": { + "Microsoft.DiaSymReader": "2.0.0", + "Microsoft.Extensions.DependencyModel": "8.0.2", + "Microsoft.Testing.Platform": "2.0.2" + } + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "5TwgTx2u7k9Al/xbZ18QXq4Hdy2xewkVTI6K3sk+jY2ykqUkIKNuj7rFu3GOV5KnEUkevhw6eZcyZs77STHJIA==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "cXmP225WcMLLOSrW8xekaNhfzdBwXX3cbXbE5qSzmLbK0KZe3z8rAObKj70FWiPPPzm2W22x0ZW93gsmAfK6Mg==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.1.0", + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "D8xmIJYQFJ6D49Rx5/vPrkZZxb338Jkew+eSqZLBfBiWKw4QZKy3i1BOXiLfz0lOmaNErwDz/YWRojCdNl+B9Q==", + "dependencies": { + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Extensions.VSTestBridge": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "bNRIEA2YoGr+Y+7LHdA7i1U80+7BAdf4K4Qh4Kx6eKkoBK/NV7QpoMg+GWPP0/eqAFzuUmUOIPVZ87Oo0Vyxmw==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Microsoft.Testing.Extensions.Telemetry": "2.1.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.1.0", + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "aHkjNTGIA+Zbdw6RJgSFrbDrCjO0CgqpElqYcvkRSeUhBv2bKarnvU3ep786U7UqrPlArT/B7VmImRibJD0Zrg==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "UpfPebXQtHGrWz21+YLHmJSm+5zsuPE9U9pfdCtoB+67g75fDmWlNgpkH2ZmdVhSwkjNIed9Icg8Iu63z2ei5Q==", + "dependencies": { + "Microsoft.Testing.Platform": "2.1.0" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.0.1", + "contentHash": "uDJKAEjFTaa2wHdWlfo6ektyoh+WD4/Eesrwb4FpBFKsLGehhACVnwwTI4qD3FrIlIEPlxdXg3SyrYRIcO+RRQ==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.0.1", + "Newtonsoft.Json": "13.0.3" + } + }, + "MSTest.Analyzers": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "4ElL/aqomiUInr090VN4udqz46AuszXLrifHkLrgj0zb7na8eAoyUQt3BwDLTcGd1bSkmk3SfD02rZtKU+ZiqQ==" + }, + "MSTest.TestAdapter": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "bRW1Hftwq0XbcVExcAbj4YAfSZDRAziL0mygDkPBvaUe2nSsWFQIatze5lHVjPFJMvSFgWnItku4pguIy5FowQ==", + "dependencies": { + "MSTest.TestFramework": "4.1.0", + "Microsoft.Testing.Extensions.VSTestBridge": "2.1.0", + "Microsoft.Testing.Platform.MSBuild": "2.1.0" + } + }, + "MSTest.TestFramework": { + "type": "Transitive", + "resolved": "4.1.0", + "contentHash": "BzpvsK+CRbk6khwY62h+7HfYzIxtJXyPv9tOI9T90cy5CVy+WI1JkN4ZaNL4Dobqb6dywSwabLTIbPZKpdrr+A==", + "dependencies": { + "MSTest.Analyzers": "4.1.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "OpenTelemetry.Api": "1.15.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SharpZipLib": { + "type": "Transitive", + "resolved": "1.4.2", + "contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==" + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "SSH.NET": { + "type": "Transitive", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "+bZnyzt0/vt4g3QSllhsRNGTpa09p7Juy5K8spcK73cOTOefu4+HoY89hZOgIOmzB5A4hqPyEDKnzra7KKnhZw==" + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "Testcontainers": { + "type": "Transitive", + "resolved": "4.10.0", + "contentHash": "a7tH+s9IRME6QEeMRgl/mTqQyudgtGNJmJRPn1+LwW8w/2L11cJzRJd7Io0QoSrP+i6lAOETX2SRY7cLbElcdQ==", + "dependencies": { + "Docker.DotNet.Enhanced": "3.131.1", + "Docker.DotNet.Enhanced.X509": "3.131.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "SSH.NET": "2025.1.0", + "SharpZipLib": "1.4.2" + } + }, + "werkr.api": { + "type": "Project", + "dependencies": { + "Grpc.AspNetCore": "[2.76.0, )", + "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.3, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.3, )", + "Microsoft.IdentityModel.JsonWebTokens": "[8.16.0, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.File": "[7.0.0, )", + "Serilog.Sinks.OpenTelemetry": "[4.2.0, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Core": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )", + "Werkr.ServiceDefaults": "[1.0.0, )" + } + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.AspNetCore.Authorization": "[10.0.3, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.core": { + "type": "Project", + "dependencies": { + "Grpc.Net.Client": "[2.76.0, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.3, )", + "System.Security.Cryptography.ProtectedData": "[10.0.3, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )" + } + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "werkr.data.identity": { + "type": "Project", + "dependencies": { + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.AspNetCore.Identity.UI": "[10.0.3, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )" + } + }, + "werkr.servicedefaults": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Grpc.AspNetCore": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==", + "dependencies": { + "Google.Protobuf": "3.31.1", + "Grpc.AspNetCore.Server.ClientFactory": "2.76.0", + "Grpc.Tools": "2.76.0" + } + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Grpc.Net.ClientFactory": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==", + "dependencies": { + "Grpc.Net.Client": "2.76.0", + "Microsoft.Extensions.Http": "8.0.0" + } + }, + "Microsoft.AspNetCore.Authentication.JwtBearer": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "TBDs8e9y2vJHp14EwNfnIZUNrm6siw8PAAU5laOrYFuGgRxx8oCdxZyfTgp1Oy/icUk9h/XtpYBHPnXIG0f2/g==", + "dependencies": { + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1" + } + }, + "Microsoft.AspNetCore.Authorization": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "6SEGWi35DZ9syBqCT8v5vEkm9tWUayWxVkHWLwW2FdyXSwS0zzEpIzGPLVQGeug3VU8d+hK/PFxFwwZnblv/zA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Identity.Stores": "10.0.3" + } + }, + "Microsoft.AspNetCore.Identity.UI": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xhxrP7QcUuyA2FcZsbvdHSqTauPseNrXzhFUYaRj+Elz1nxJceKbW+COc1P9QbpKeZDh9aTDSldHbz3AnMWOqg==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Embedded": "10.0.3", + "Microsoft.Extensions.Identity.Stores": "10.0.3" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "SAvSrKDgnY5GDjDAngOXxPhUvEKlTU/0zIq8zidqHvh/xnZBPs0Vc4LqwyvnmnafNnyUaivtRABz4K4wodXfSg==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.3.0", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Resilience": "10.3.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.3", + "contentHash": "OoH8AcYCq74ab5XUIQc84CZk54G/cU+JztiMXgNKGkomJOeuistTMg0PWPC4VXXMSVBEGWJuMDEBttOrHyXe8w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.ServiceDiscovery": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "OpenTelemetry": "1.15.0" + } + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Sinks.OpenTelemetry": { + "type": "CentralTransitive", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", + "dependencies": { + "Google.Protobuf": "3.30.1", + "Grpc.Net.Client": "2.70.0", + "Serilog": "4.2.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.0.1", + "contentHash": "GJw3bYkWpOgvN3tJo5X4lYUeIFA2HD293FPUhKmp7qxS+g5ywAb34Dnd3cDAFLkcMohy5XTpoaZ4uAHuw0uSPQ==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.0.1", + "Microsoft.IdentityModel.Tokens": "8.0.1" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" + } + } + } +} \ No newline at end of file diff --git a/src/Werkr.Agent/Communication/AgentGrpcClientFactory.cs b/src/Werkr.Agent/Communication/AgentGrpcClientFactory.cs new file mode 100644 index 0000000..e9b672b --- /dev/null +++ b/src/Werkr.Agent/Communication/AgentGrpcClientFactory.cs @@ -0,0 +1,218 @@ +using Grpc.Core; +using Grpc.Net.Client; + +using Microsoft.EntityFrameworkCore; + +using Werkr.Common.Models; +using Werkr.Common.Protos; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Agent.Communication; + +/// +/// Manages the Agent's outbound gRPC channel to the Server. +/// On first use, reads the (where IsServer == false) +/// from the Agent's local SQLite database. Creates and caches a +/// to the Server's . +/// Provides typed client accessors and with bearer token authentication. +/// +/// Factory for creating DI scopes to resolve . +/// Logger instance. +public sealed class AgentGrpcClientFactory( + IServiceScopeFactory scopeFactory, + ILogger logger +) : IDisposable { + private GrpcChannel? _channel; + private RegisteredConnection? _connection; + private readonly SemaphoreSlim _initLock = new( 1, 1 ); + + /// + /// Gets the cached for the Server. + /// Initializes on first call. + /// + /// Cancellation token. + /// The Agent's registration connection record. + /// No registration found. + public async Task GetConnectionAsync( CancellationToken ct = default ) { + await EnsureInitializedAsync( ct ); + return _connection!; + } + + /// + /// Creates a for pulling schedules from the Server. + /// + /// Cancellation token. + /// A configured gRPC client. + public async Task CreateScheduleSyncClientAsync( CancellationToken ct = default ) { + await EnsureInitializedAsync( ct ); + return new ScheduleSync.ScheduleSyncClient( _channel ); + } + + /// + /// Creates a for reporting job results to the Server. + /// + /// Cancellation token. + /// A configured gRPC client. + public async Task CreateJobReportingClientAsync( CancellationToken ct = default ) { + await EnsureInitializedAsync( ct ); + return new JobReporting.JobReportingClient( _channel ); + } + + /// + /// Creates a for requesting workflow execution on the Server. + /// + /// Cancellation token. + /// A configured gRPC client. + public async Task CreateWorkflowExecutionClientAsync( CancellationToken ct = default ) { + await EnsureInitializedAsync( ct ); + return new WorkflowExecution.WorkflowExecutionClient( _channel ); + } + + /// + /// Creates gRPC with bearer token, connection ID, call ID, and deadline. + /// Mirrors the pattern from AgentConnectionManager.CreateCallOptions. + /// + /// Optional call ID for tracing. Generated if null. + /// Cancellation token. + /// Call timeout. Defaults to 5 minutes if null. + /// Configured . + public CallOptions CreateCallOptions( + Guid? callId = null, + CancellationToken cancellationToken = default, + TimeSpan? timeout = null ) { + + if (_connection is null) { + throw new InvalidOperationException( "AgentGrpcClientFactory has not been initialized. Call any Create*Client method first." ); + } + + Metadata metadata = new( ) { + { "authorization", $"Bearer {_connection.OutboundApiKey}" }, + { "x-werkr-connection-id", _connection.Id.ToString( ) }, + { "x-werkr-call-id", ( callId ?? Guid.NewGuid( ) ).ToString( ) } + }; + + TimeSpan effectiveTimeout = timeout ?? TimeSpan.FromMinutes( 5 ); + DateTime deadline = DateTime.UtcNow + effectiveTimeout; + + return new CallOptions( + headers: metadata, + deadline: deadline, + cancellationToken: cancellationToken ); + } + + /// + /// Gets the SharedKey for encrypting outbound payloads. + /// Must be called after initialization (any Create*Client call or GetConnectionAsync). + /// + /// The 32-byte AES-256 shared key. + /// Thrown when not yet initialized. + public byte[] GetSharedKey( ) { + return _connection?.SharedKey + ?? throw new InvalidOperationException( + "AgentGrpcClientFactory has not been initialized. Call any Create*Client method first." ); + } + + /// + /// Gets the active key identifier for envelope encryption. + /// Falls back to the connection ID if no rotation has occurred. + /// Must be called after initialization. + /// + /// The active key ID string. + /// Thrown when not yet initialized. + public string GetKeyId( ) { + return _connection is null + ? throw new InvalidOperationException( + "AgentGrpcClientFactory has not been initialized. Call any Create*Client method first." ) + : _connection.ActiveKeyId ?? _connection.Id.ToString( ); + } + + /// + /// Returns whether the Agent has been registered (has a connection to the Server). + /// + /// Cancellation token. + /// True if a valid registration exists. + public async Task IsRegisteredAsync( CancellationToken ct = default ) { + try { + await EnsureInitializedAsync( ct ); + return _connection is not null; + } catch (InvalidOperationException) { + return false; + } + } + + /// + /// Resets the cached channel and connection, forcing re-initialization on next use. + /// Call when the connection details may have changed (e.g., after re-registration). + /// + public void Reset( ) { + _channel?.Dispose( ); + _channel = null; + _connection = null; + } + + /// + public void Dispose( ) { + _channel?.Dispose( ); + _initLock.Dispose( ); + } + + /// + /// Ensures the gRPC channel and connection are initialized. + /// Thread-safe via . + /// + private async Task EnsureInitializedAsync( CancellationToken ct ) { + if (_channel is not null && _connection is not null && _channel.State != ConnectivityState.Shutdown) { + return; + } + + await _initLock.WaitAsync( ct ); + try { + // Double-check after acquiring lock + if (_channel is not null && _connection is not null && _channel.State != ConnectivityState.Shutdown) { + return; + } + + _connection = await ResolveConnectionAsync( ct ); + + _channel?.Dispose( ); + _channel = GrpcChannel.ForAddress( _connection.RemoteUrl, new GrpcChannelOptions { + HttpHandler = CreateHttpHandler( ) + } ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Created gRPC channel to Server at {Url} (Connection: {ConnectionId}).", + _connection.RemoteUrl, _connection.Id.ToString( ) ); + } + } finally { + _ = _initLock.Release( ); + } + } + + /// + /// Resolves the Agent's from the local SQLite database. + /// The Agent side has IsServer == false. + /// + private async Task ResolveConnectionAsync( CancellationToken ct ) { + using IServiceScope scope = scopeFactory.CreateScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + RegisteredConnection? connection = await dbContext.RegisteredConnections + .AsNoTracking( ) + .FirstOrDefaultAsync( c => !c.IsServer && c.Status == ConnectionStatus.Connected, ct ); + + return connection ?? throw new InvalidOperationException( + "No registered server connection found. The agent must complete registration before scheduling can begin." ); + } + + /// + /// Creates an for gRPC channels. + /// All connections use TLS — ALPN negotiates HTTP/2 automatically. + /// + private static SocketsHttpHandler CreateHttpHandler( ) => new( ) { + EnableMultipleHttp2Connections = true, + KeepAlivePingDelay = TimeSpan.FromSeconds( 30 ), + KeepAlivePingTimeout = TimeSpan.FromSeconds( 10 ), + }; +} diff --git a/src/Werkr.Agent/Dockerfile b/src/Werkr.Agent/Dockerfile new file mode 100644 index 0000000..f707913 --- /dev/null +++ b/src/Werkr.Agent/Dockerfile @@ -0,0 +1,112 @@ +# syntax=docker/dockerfile:1 +# ------------------------------------------------------------------- +# Werkr.Agent — Background agent with gRPC, SQLCipher, PowerShell +# +# Two build modes (controlled by BUILD_MODE arg): +# source (default) — builds from source using the SDK image +# deb — installs pre-built .deb from Publish/ +# +# For .deb mode, run publish.ps1 first: +# pwsh scripts/publish.ps1 -Application Agent -Platform linux -Architecture x64 -BuildDebInstallers +# docker compose build --build-arg BUILD_MODE=deb +# +# Or use the wrapper script: +# ./scripts/docker-build.sh --deb +# +# Build context: repository root (Werkr_Final/) +# ------------------------------------------------------------------- + +ARG BUILD_MODE=source + +# ========================== +# Source build stage +# ========================== +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS source-build +ARG TARGETARCH +WORKDIR /src + +COPY Directory.Build.props Directory.Packages.props global.json Werkr.slnx ./ + +COPY src/Werkr.ServiceDefaults/Werkr.ServiceDefaults.csproj src/Werkr.ServiceDefaults/ +COPY src/Werkr.Common.Configuration/Werkr.Common.Configuration.csproj src/Werkr.Common.Configuration/ +COPY src/Werkr.Common/Werkr.Common.csproj src/Werkr.Common/ +COPY src/Werkr.Core/Werkr.Core.csproj src/Werkr.Core/ +COPY src/Werkr.Data/Werkr.Data.csproj src/Werkr.Data/ +COPY src/Werkr.Agent/Werkr.Agent.csproj src/Werkr.Agent/ + +# Proto files needed during restore/build for gRPC codegen +COPY src/Werkr.Api/Protos/ src/Werkr.Api/Protos/ +COPY src/Werkr.Agent/Protos/ src/Werkr.Agent/Protos/ + +RUN dotnet restore src/Werkr.Agent/Werkr.Agent.csproj \ + -r linux-${TARGETARCH} + +COPY src/ src/ +RUN dotnet publish src/Werkr.Agent/Werkr.Agent.csproj \ + -c Release -r linux-${TARGETARCH} -o /app/publish \ + --sc true + +# ========================== +# Source runtime +# ========================== +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS source + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system werkr \ + && useradd --system --no-create-home -g werkr werkr \ + && mkdir -p /var/lib/werkr /home/werkr/.config/werkr/secrets \ + && chown -R werkr:werkr /var/lib/werkr /home/werkr + +WORKDIR /app + +RUN mkdir -p /app/certs /app/job-output \ + && chown -R werkr:werkr /app/certs /app/job-output + +COPY --from=source-build --chown=werkr:werkr /app/publish . + +EXPOSE 8443 + +ENV ASPNETCORE_URLS=https://+:8443 \ + ASPNETCORE_ENVIRONMENT=Production \ + WERKR_DATA_DIR=/var/lib/werkr \ + DOTNET_ENVIRONMENT=Production \ + HOME=/home/werkr + +USER werkr +ENTRYPOINT ["./Werkr.Agent"] + +# ========================== +# Deb runtime +# ========================== +FROM debian:bookworm-slim AS deb + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl libicu72 libssl3 \ + && rm -rf /var/lib/apt/lists/* + +COPY Publish/Werkr.Agent.*.deb /tmp/ +RUN dpkg -x /tmp/*.deb / && rm /tmp/*.deb + +RUN groupadd --system werkr \ + && useradd --system --no-create-home -g werkr werkr \ + && mkdir -p /var/lib/werkr /opt/werkr/agent/certs /home/werkr/.config/werkr/secrets \ + && chown -R werkr:werkr /var/lib/werkr /opt/werkr/agent/certs /home/werkr + +USER werkr +WORKDIR /opt/werkr/agent + +EXPOSE 8443 + +ENV ASPNETCORE_URLS=https://+:8443 \ + ASPNETCORE_ENVIRONMENT=Production \ + DOTNET_ENVIRONMENT=Production \ + WERKR_DATA_DIR=/var/lib/werkr \ + HOME=/home/werkr + +ENTRYPOINT ["./Werkr.Agent"] + +# ========================== +# Final (selects mode via BUILD_MODE arg) +# ========================== +FROM ${BUILD_MODE} AS final diff --git a/src/Werkr.Agent/Interceptors/BearerTokenInterceptor.cs b/src/Werkr.Agent/Interceptors/BearerTokenInterceptor.cs new file mode 100644 index 0000000..e49a240 --- /dev/null +++ b/src/Werkr.Agent/Interceptors/BearerTokenInterceptor.cs @@ -0,0 +1,105 @@ +using System.Security.Cryptography; +using System.Text; + +using Grpc.Core; +using Grpc.Core.Interceptors; + +using Microsoft.EntityFrameworkCore; + +using Werkr.Common.Models; +using Werkr.Core.Cryptography; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Agent.Interceptors; + +/// +/// gRPC server interceptor that validates bearer token authentication and connection ID +/// on all incoming calls. Resolves the and stores it +/// in context.UserState for downstream services. +/// +/// Creates a new . +/// Factory for creating DI scopes to resolve . +/// Logger for diagnostics. +public class BearerTokenInterceptor( + IServiceScopeFactory scopeFactory, + ILogger logger +) : Interceptor { + + /// + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation + ) { + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Received gRPC call to {Method} from {Peer}", context.Method, context.Peer ); + } + + await ValidateAndAttachConnectionAsync( context ); + return await continuation( request, context ); + } + + /// + public override async Task ServerStreamingServerHandler( + TRequest request, + IServerStreamWriter responseStream, + ServerCallContext context, + ServerStreamingServerMethod continuation + ) { + + await ValidateAndAttachConnectionAsync( context ); + await continuation( request, responseStream, context ); + } + + private async Task ValidateAndAttachConnectionAsync( ServerCallContext context ) { + // Extract authorization header + string? authHeader = context.RequestHeaders.GetValue( "authorization" ); + if (string.IsNullOrEmpty( authHeader ) || !authHeader.StartsWith( "Bearer ", StringComparison.OrdinalIgnoreCase )) { + throw new RpcException( new Status( StatusCode.Unauthenticated, "Missing authentication credentials." ) ); + } + + string token = authHeader["Bearer ".Length..]; + + // Extract connection ID header + string? connectionIdStr = context.RequestHeaders.GetValue( "x-werkr-connection-id" ); + if (string.IsNullOrEmpty( connectionIdStr ) || !Guid.TryParse( connectionIdStr, out Guid connectionId )) { + throw new RpcException( new Status( StatusCode.Unauthenticated, "Missing authentication credentials." ) ); + } + + // Resolve connection from database + using IServiceScope scope = scopeFactory.CreateScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + RegisteredConnection? connection = await dbContext.RegisteredConnections + .FirstOrDefaultAsync( c => c.Id == connectionId && !c.IsServer ); + + if (connection is null || connection.Status == ConnectionStatus.Revoked) { + throw new RpcException( new Status( StatusCode.Unauthenticated, "Connection not found or revoked." ) ); + } + + // Constant-time token comparison + string tokenHash = EncryptionProvider.HashSHA512String( token ); + byte[] receivedBytes = Encoding.UTF8.GetBytes( tokenHash ); + byte[] storedBytes = Encoding.UTF8.GetBytes( connection.InboundApiKeyHash ); + + if (!CryptographicOperations.FixedTimeEquals( receivedBytes, storedBytes )) { + throw new RpcException( new Status( StatusCode.Unauthenticated, "Invalid bearer token." ) ); + } + + // Debounced LastSeen update (only write if null or older than 60 seconds) + if (connection.LastSeen is null || connection.LastSeen < DateTime.UtcNow.AddSeconds( -60 )) { + connection.LastSeen = DateTime.UtcNow; + _ = await dbContext.SaveChangesAsync( ); + } + + // Store resolved connection in UserState for downstream services + context.UserState["Connection"] = connection; + + // Store CallId if present + string? callId = context.RequestHeaders.GetValue( "x-werkr-call-id" ); + if (!string.IsNullOrEmpty( callId )) { + context.UserState["CallId"] = callId; + } + } +} diff --git a/src/Werkr.Agent/Operators/ActionHandlerServiceCollectionExtensions.cs b/src/Werkr.Agent/Operators/ActionHandlerServiceCollectionExtensions.cs new file mode 100644 index 0000000..4d8e8ed --- /dev/null +++ b/src/Werkr.Agent/Operators/ActionHandlerServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Werkr.Core.Operators; + +namespace Werkr.Agent.Operators; + +/// +/// Service collection extensions for registering built-in action handlers. +/// +public static class ActionHandlerServiceCollectionExtensions { + /// + /// Registers all implementations from the + /// Werkr.Agent assembly as singleton services. + /// + /// Service collection. + /// The same for chaining. + public static IServiceCollection AddActionHandlers( this IServiceCollection services ) { + Type actionHandlerType = typeof( IActionHandler ); + IEnumerable handlerTypes = typeof( ActionHandlerServiceCollectionExtensions ) + .Assembly + .GetTypes( ) + .Where( t => actionHandlerType.IsAssignableFrom( t ) + && !t.IsAbstract + && !t.IsInterface ); + + foreach (Type handlerType in handlerTypes) { + _ = services.AddSingleton( actionHandlerType, handlerType ); + } + + return services; + } +} diff --git a/src/Werkr.Agent/Operators/ActionOperator.cs b/src/Werkr.Agent/Operators/ActionOperator.cs new file mode 100644 index 0000000..a79d301 --- /dev/null +++ b/src/Werkr.Agent/Operators/ActionOperator.cs @@ -0,0 +1,189 @@ +using System.Threading.Channels; +using Microsoft.Extensions.Options; + +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; + +namespace Werkr.Agent.Operators; + +/// +/// Built-in action operator — dispatches requests +/// to the appropriate via a string-keyed registry. +/// Follows the same channel-based streaming pattern as +/// and . +/// +public sealed class ActionOperator : IActionOperator { + + /// + /// The well-known action names that must have registered handlers at startup. + /// Adding a new action: handler class + parameter record + one string here. + /// + internal static readonly string[] DefaultExpectedActions = [ + "CopyFile", + "MoveFile", + "RenameFile", + "DeleteFile", + "CreateFile", + "CreateDirectory", + "TestExists", + "ClearContent", + "WriteContent", + "StartProcess", + "StopProcess", + ]; + + private readonly Dictionary _handlers; + private readonly IOptionsMonitor _options; + private readonly ILogger _logger; + + /// + /// Creates a new and builds the handler registry. + /// + /// All registered action handlers from DI. + /// Configuration for action operator behavior (e.g. timeout). + /// Logger for diagnostics. + /// + /// Optional list of expected action names to validate at startup. When null, + /// defaults to . Pass an empty collection to + /// skip validation (useful in unit tests with partial handler sets). + /// + /// + /// Thrown when duplicate action names are detected or expected actions are missing. + /// + public ActionOperator( + IEnumerable handlers, + IOptionsMonitor options, + ILogger logger, + IEnumerable? expectedActions = null ) { + _options = options; + _logger = logger; + _handlers = new Dictionary( StringComparer.OrdinalIgnoreCase ); + + foreach (IActionHandler handler in handlers) { + if (!_handlers.TryAdd( handler.Action, handler )) { + throw new InvalidOperationException( + $"Duplicate action handler registered for action '{handler.Action}'. " + + $"Existing: {_handlers[handler.Action].GetType( ).Name}, " + + $"Duplicate: {handler.GetType( ).Name}" ); + } + } + + // Validate that all expected actions have registered handlers. + IEnumerable actionsToValidate = expectedActions ?? DefaultExpectedActions; + List missing = []; + foreach (string action in actionsToValidate) { + if (!_handlers.ContainsKey( action )) { + missing.Add( action ); + } + } + + if (missing.Count > 0) { + throw new InvalidOperationException( + $"Missing action handler registrations: [{string.Join( ", ", missing )}]. " + + $"Ensure all IActionHandler implementations are registered via AddActionHandlers()." ); + } + + if (_logger.IsEnabled( LogLevel.Information )) { + _logger.LogInformation( "ActionOperator initialized with {Count} handlers: [{Actions}]", + _handlers.Count, string.Join( ", ", _handlers.Keys ) ); + } + } + + /// + public OperatorExecution Execute( ActionDescriptor descriptor, CancellationToken cancellationToken = default ) { + Channel channel = Channel.CreateBounded( + new BoundedChannelOptions( 10_000 ) { FullMode = BoundedChannelFullMode.Wait, SingleWriter = false } ); + + TaskCompletionSource resultTcs = new( TaskCreationOptions.RunContinuationsAsynchronously ); + + _ = ExecuteInternal( descriptor, channel.Writer, resultTcs, cancellationToken ); + + return new OperatorExecution( channel.Reader.ReadAllAsync( cancellationToken ), resultTcs.Task ); + } + + private async Task ExecuteInternal( + ActionDescriptor descriptor, + ChannelWriter writer, + TaskCompletionSource resultTcs, + CancellationToken cancellationToken ) { + + CancellationTokenSource? timeoutCts = null; + CancellationTokenSource? linkedCts = null; + + try { + if (!_handlers.TryGetValue( descriptor.Action, out IActionHandler? handler )) { + string message = $"No handler registered for action '{descriptor.Action}'. " + + $"Available actions: [{string.Join( ", ", _handlers.Keys )}]"; + _logger.LogError( "{Message}", message ); + await writer.WriteAsync( + OperatorOutput.Create( LogLevel.Error, message ), cancellationToken ); + resultTcs.SetResult( new ActionOperatorResult( + Success: false, + Exception: new InvalidOperationException( message ) ) ); + return; + } + + if (_logger.IsEnabled( LogLevel.Information )) { + _logger.LogInformation( "Executing action '{Action}' via handler {Handler}", + descriptor.Action, handler.GetType( ).Name ); + } + + await writer.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Starting action: {descriptor.Action}" ), + cancellationToken ); + + // Apply configurable timeout if set. + TimeSpan? timeout = _options.CurrentValue.DefaultTimeout; + CancellationToken handlerToken = cancellationToken; + + if (timeout.HasValue) { + timeoutCts = new CancellationTokenSource( ); + timeoutCts.CancelAfter( timeout.Value ); + linkedCts = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, timeoutCts.Token ); + handlerToken = linkedCts.Token; + } + + ActionOperatorResult result = await handler.ExecuteAsync( + descriptor.Parameters, writer, handlerToken ); + + if (_logger.IsEnabled( LogLevel.Information )) { + _logger.LogInformation( "Action '{Action}' completed. Success: {Success}", + descriptor.Action, result.Success ); + } + + resultTcs.SetResult( result ); + } catch (OperationCanceledException ex) when (timeoutCts is not null && timeoutCts.IsCancellationRequested) { + // Timeout — distinguish from caller cancellation. + TimeSpan timeout = _options.CurrentValue.DefaultTimeout!.Value; + _logger.LogWarning( "Action '{Action}' timed out after {Timeout}", descriptor.Action, timeout ); + try { + await writer.WriteAsync( + OperatorOutput.Create( LogLevel.Warning, + $"Action '{descriptor.Action}' timed out after {timeout}." ), + CancellationToken.None ); + } catch { + // Channel may be completed; swallow + } + resultTcs.SetResult( new ActionOperatorResult( Success: false, Exception: ex ) ); + } catch (OperationCanceledException ex) { + _logger.LogWarning( "Action '{Action}' was cancelled", descriptor.Action ); + resultTcs.SetResult( new ActionOperatorResult( Success: false, Exception: ex ) ); + } catch (Exception ex) { + _logger.LogError( ex, "Action '{Action}' failed with unhandled exception", descriptor.Action ); + try { + await writer.WriteAsync( + OperatorOutput.Create( LogLevel.Error, $"Action failed: {ex.Message}" ), + cancellationToken ); + } catch { + // Channel may be completed; swallow + } + resultTcs.SetResult( new ActionOperatorResult( Success: false, Exception: ex ) ); + } finally { + _ = writer.TryComplete( ); + timeoutCts?.Dispose( ); + linkedCts?.Dispose( ); + } + } +} diff --git a/src/Werkr.Agent/Operators/Actions/ActionJson.cs b/src/Werkr.Agent/Operators/Actions/ActionJson.cs new file mode 100644 index 0000000..defa9e4 --- /dev/null +++ b/src/Werkr.Agent/Operators/Actions/ActionJson.cs @@ -0,0 +1,12 @@ +using System.Text.Json; + +namespace Werkr.Agent.Operators.Actions; + +/// +/// Shared JSON serializer options for action handler parameter deserialization. +/// +internal static class ActionJson { + internal static readonly JsonSerializerOptions SerializerOptions = new( ) { + PropertyNameCaseInsensitive = true, + }; +} diff --git a/src/Werkr.Agent/Operators/Actions/ClearContentHandler.cs b/src/Werkr.Agent/Operators/Actions/ClearContentHandler.cs new file mode 100644 index 0000000..603afe7 --- /dev/null +++ b/src/Werkr.Agent/Operators/Actions/ClearContentHandler.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Core.Security; + +namespace Werkr.Agent.Operators.Actions; + +/// +/// Handles the ClearContent action — clears the content of a file (truncates to zero bytes). +/// +public sealed class ClearContentHandler : IActionHandler { + + private readonly IFilePathResolver _resolver; + private readonly ILogger _logger; + + /// Creates a new . + public ClearContentHandler( IFilePathResolver resolver, ILogger logger ) { + _resolver = resolver; + _logger = logger; + } + + /// + public string Action => "ClearContent"; + + /// + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + try { + ClearContentParameters p = parameters.Deserialize( ActionJson.SerializerOptions ) + ?? throw new ArgumentException( "Failed to deserialize ClearContent parameters." ); + + string fullPath = _resolver.ResolveSinglePath( p.Path ); + + if (!File.Exists( fullPath )) { + throw new FileNotFoundException( $"File '{fullPath}' does not exist.", fullPath ); + } + + await File.WriteAllTextAsync( fullPath, string.Empty, cancellationToken ); + + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Cleared content of '{fullPath}'" ), + cancellationToken ); + + return new ActionOperatorResult( Success: true ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError( ex, "ClearContent action failed" ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Error, $"ClearContent failed: {ex.Message}" ), + cancellationToken ); + return new ActionOperatorResult( Success: false, Exception: ex ); + } + } +} diff --git a/src/Werkr.Agent/Operators/Actions/CopyFileHandler.cs b/src/Werkr.Agent/Operators/Actions/CopyFileHandler.cs new file mode 100644 index 0000000..ebe6cec --- /dev/null +++ b/src/Werkr.Agent/Operators/Actions/CopyFileHandler.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Core.Security; + +namespace Werkr.Agent.Operators.Actions; + +/// +/// Handles the CopyFile action — copies files or directories from source to destination. +/// Supports wildcard file resolution and recursive directory copy. +/// +public sealed class CopyFileHandler : IActionHandler { + + private readonly IFilePathResolver _resolver; + private readonly ILogger _logger; + + /// Creates a new . + public CopyFileHandler( IFilePathResolver resolver, ILogger logger ) { + _resolver = resolver; + _logger = logger; + } + + /// + public string Action => "CopyFile"; + + /// + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + try { + CopyFileParameters p = parameters.Deserialize( ActionJson.SerializerOptions ) + ?? throw new ArgumentException( "Failed to deserialize CopyFile parameters." ); + + _resolver.ValidateSourceDestination( p.Source, p.Destination ); + + string source = Path.GetFullPath( p.Source ); + string destination = _resolver.ResolveSinglePath( p.Destination ); + + if (Directory.Exists( source )) { + // Directory copy + bool result = CopyDirectory( source, destination, p.Overwrite, p.Recursive ); + if (result) { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Copied directory '{source}' → '{destination}'" ), + cancellationToken ); + } else { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Warning, $"Source directory '{source}' does not exist." ), + cancellationToken ); + return new ActionOperatorResult( Success: false ); + } + } else { + // File copy (supports wildcards) + string[] files = _resolver.ResolveFiles( source ); + if (files.Length == 0) { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Warning, $"No files found matching '{source}'." ), + cancellationToken ); + return new ActionOperatorResult( Success: false ); + } + + foreach (string file in files) { + cancellationToken.ThrowIfCancellationRequested( ); + string dest = Directory.Exists( destination ) + ? Path.Join( destination, Path.GetFileName( file ) ) + : destination; + File.Copy( file, dest, p.Overwrite ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Copied '{file}' → '{dest}'" ), + cancellationToken ); + } + } + + return new ActionOperatorResult( Success: true ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError( ex, "CopyFile action failed" ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Error, $"CopyFile failed: {ex.Message}" ), + cancellationToken ); + return new ActionOperatorResult( Success: false, Exception: ex ); + } + } + + private static bool CopyDirectory( string source, string destination, bool overwrite, bool recursive ) { + DirectoryInfo dir = new( source ); + if (!dir.Exists) { return false; } + + DirectoryInfo[] subDirs = dir.GetDirectories( ); + _ = Directory.CreateDirectory( destination ); + + foreach (FileInfo file in dir.GetFiles( )) { + string targetPath = Path.Join( destination, file.Name ); + _ = file.CopyTo( targetPath, overwrite ); + } + + if (recursive) { + foreach (DirectoryInfo subDir in subDirs) { + string newDest = Path.Join( destination, subDir.Name ); + _ = CopyDirectory( subDir.FullName, newDest, overwrite, recursive ); + } + } else { + foreach (DirectoryInfo subDir in subDirs) { + _ = Directory.CreateDirectory( Path.Join( destination, subDir.Name ) ); + } + } + + return true; + } +} diff --git a/src/Werkr.Agent/Operators/Actions/CreateDirectoryHandler.cs b/src/Werkr.Agent/Operators/Actions/CreateDirectoryHandler.cs new file mode 100644 index 0000000..667f7db --- /dev/null +++ b/src/Werkr.Agent/Operators/Actions/CreateDirectoryHandler.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Core.Security; + +namespace Werkr.Agent.Operators.Actions; + +/// +/// Handles the CreateDirectory action — creates a new directory (including parent directories). +/// +public sealed class CreateDirectoryHandler : IActionHandler { + + private readonly IFilePathResolver _resolver; + private readonly ILogger _logger; + + /// Creates a new . + public CreateDirectoryHandler( IFilePathResolver resolver, ILogger logger ) { + _resolver = resolver; + _logger = logger; + } + + /// + public string Action => "CreateDirectory"; + + /// + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + try { + CreateDirectoryParameters p = parameters.Deserialize( ActionJson.SerializerOptions ) + ?? throw new ArgumentException( "Failed to deserialize CreateDirectory parameters." ); + + string fullPath = _resolver.ResolveSinglePath( p.Path ); + + if (File.Exists( fullPath )) { + throw new ArgumentException( $"Path '{fullPath}' already exists as a file. Cannot create directory." ); + } + + if (Directory.Exists( fullPath )) { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Directory '{fullPath}' already exists." ), + cancellationToken ); + return new ActionOperatorResult( Success: true ); + } + + _ = Directory.CreateDirectory( fullPath ); + + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Created directory '{fullPath}'" ), + cancellationToken ); + + return new ActionOperatorResult( Success: true ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError( ex, "CreateDirectory action failed" ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Error, $"CreateDirectory failed: {ex.Message}" ), + cancellationToken ); + return new ActionOperatorResult( Success: false, Exception: ex ); + } + } +} diff --git a/src/Werkr.Agent/Operators/Actions/CreateFileHandler.cs b/src/Werkr.Agent/Operators/Actions/CreateFileHandler.cs new file mode 100644 index 0000000..a6e62ff --- /dev/null +++ b/src/Werkr.Agent/Operators/Actions/CreateFileHandler.cs @@ -0,0 +1,86 @@ +using System.Text; +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Core.Security; + +namespace Werkr.Agent.Operators.Actions; + +/// +/// Handles the CreateFile action — creates a new file with optional content. +/// Optionally creates parent directories. +/// +public sealed class CreateFileHandler : IActionHandler { + + private readonly IFilePathResolver _resolver; + private readonly ILogger _logger; + + /// Creates a new . + public CreateFileHandler( IFilePathResolver resolver, ILogger logger ) { + _resolver = resolver; + _logger = logger; + } + + /// + public string Action => "CreateFile"; + + /// + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + try { + CreateFileParameters p = parameters.Deserialize( ActionJson.SerializerOptions ) + ?? throw new ArgumentException( "Failed to deserialize CreateFile parameters." ); + + string fullPath = _resolver.ResolveSinglePath( p.Path ); + + FileInfo fileInfo = new( fullPath ); + string parentDir = fileInfo.DirectoryName + ?? throw new InvalidOperationException( "Path must be rooted under a directory." ); + + if (!Directory.Exists( parentDir )) { + if (p.CreateParentDirectories) { + _ = Directory.CreateDirectory( parentDir ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Created parent directory '{parentDir}'" ), + cancellationToken ); + } else { + throw new DirectoryNotFoundException( $"Parent directory '{parentDir}' does not exist." ); + } + } + + if (File.Exists( fullPath ) || Directory.Exists( fullPath )) { + if (!p.Overwrite) { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Warning, $"Path '{fullPath}' already exists and Overwrite is false." ), + cancellationToken ); + return new ActionOperatorResult( Success: false ); + } + } + + Encoding encoding = Encoding.GetEncoding( p.Encoding ); + + if (p.Content != null) { + await File.WriteAllTextAsync( fullPath, p.Content, encoding, cancellationToken ); + } else { + await using FileStream fs = File.Create( fullPath ); + } + + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Created file '{fullPath}'" ), + cancellationToken ); + + return new ActionOperatorResult( Success: true ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError( ex, "CreateFile action failed" ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Error, $"CreateFile failed: {ex.Message}" ), + cancellationToken ); + return new ActionOperatorResult( Success: false, Exception: ex ); + } + } +} diff --git a/src/Werkr.Agent/Operators/Actions/DeleteFileHandler.cs b/src/Werkr.Agent/Operators/Actions/DeleteFileHandler.cs new file mode 100644 index 0000000..0fbec70 --- /dev/null +++ b/src/Werkr.Agent/Operators/Actions/DeleteFileHandler.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Core.Security; + +namespace Werkr.Agent.Operators.Actions; + +/// +/// Handles the DeleteFile action — deletes a file or directory. +/// Supports recursive deletion and forced removal of read-only files. +/// +public sealed class DeleteFileHandler : IActionHandler { + + private readonly IFilePathResolver _resolver; + private readonly ILogger _logger; + + /// Creates a new . + public DeleteFileHandler( IFilePathResolver resolver, ILogger logger ) { + _resolver = resolver; + _logger = logger; + } + + /// + public string Action => "DeleteFile"; + + /// + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + try { + DeleteFileParameters p = parameters.Deserialize( ActionJson.SerializerOptions ) + ?? throw new ArgumentException( "Failed to deserialize DeleteFile parameters." ); + + string fullPath = _resolver.ResolveSinglePath( p.Path ); + + if (p.Force) { + RemoveReadOnlyAttribute( fullPath, p.Recursive ); + } + + if (Directory.Exists( fullPath )) { + Directory.Delete( fullPath, p.Recursive ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Deleted directory '{fullPath}'" ), + cancellationToken ); + } else if (File.Exists( fullPath )) { + File.Delete( fullPath ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Deleted file '{fullPath}'" ), + cancellationToken ); + } else { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Warning, $"Path '{fullPath}' does not exist." ), + cancellationToken ); + return new ActionOperatorResult( Success: false ); + } + + return new ActionOperatorResult( Success: true ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError( ex, "DeleteFile action failed" ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Error, $"DeleteFile failed: {ex.Message}" ), + cancellationToken ); + return new ActionOperatorResult( Success: false, Exception: ex ); + } + } + + private static void RemoveReadOnlyAttribute( string path, bool recursive ) { + if (File.Exists( path )) { + FileInfo info = new( path ); + File.SetAttributes( info.FullName, info.Attributes & ~FileAttributes.ReadOnly ); + } else if (Directory.Exists( path )) { + DirectoryInfo dir = new( path ); + if (recursive) { + foreach (FileSystemInfo fileInfo in dir.GetFileSystemInfos( "*", SearchOption.AllDirectories )) { + if (File.Exists( fileInfo.FullName )) { + File.SetAttributes( fileInfo.FullName, fileInfo.Attributes & ~FileAttributes.ReadOnly ); + } + } + } + } + } +} diff --git a/src/Werkr.Agent/Operators/Actions/MoveFileHandler.cs b/src/Werkr.Agent/Operators/Actions/MoveFileHandler.cs new file mode 100644 index 0000000..71d858b --- /dev/null +++ b/src/Werkr.Agent/Operators/Actions/MoveFileHandler.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Core.Security; + +namespace Werkr.Agent.Operators.Actions; + +/// +/// Handles the MoveFile action — moves files or directories from source to destination. +/// Supports wildcard file resolution. Directory move is implemented as copy + delete. +/// +public sealed class MoveFileHandler : IActionHandler { + + private readonly IFilePathResolver _resolver; + private readonly ILogger _logger; + + /// Creates a new . + public MoveFileHandler( IFilePathResolver resolver, ILogger logger ) { + _resolver = resolver; + _logger = logger; + } + + /// + public string Action => "MoveFile"; + + /// + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + try { + MoveFileParameters p = parameters.Deserialize( ActionJson.SerializerOptions ) + ?? throw new ArgumentException( "Failed to deserialize MoveFile parameters." ); + + _resolver.ValidateSourceDestination( p.Source, p.Destination ); + + string source = Path.GetFullPath( p.Source ); + string destination = _resolver.ResolveSinglePath( p.Destination ); + + if (Directory.Exists( source )) { + // Directory move (copy + delete) + CopyDirectoryRecursive( source, destination, p.Overwrite ); + Directory.Delete( source, recursive: true ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Moved directory '{source}' → '{destination}'" ), + cancellationToken ); + } else { + // File move (supports wildcards) + string[] files = _resolver.ResolveFiles( source ); + if (files.Length == 0) { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Warning, $"No files found matching '{source}'." ), + cancellationToken ); + return new ActionOperatorResult( Success: false ); + } + + foreach (string file in files) { + cancellationToken.ThrowIfCancellationRequested( ); + string dest = Directory.Exists( destination ) + ? Path.Join( destination, Path.GetFileName( file ) ) + : destination; + File.Move( file, dest, p.Overwrite ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Moved '{file}' → '{dest}'" ), + cancellationToken ); + } + } + + return new ActionOperatorResult( Success: true ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError( ex, "MoveFile action failed" ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Error, $"MoveFile failed: {ex.Message}" ), + cancellationToken ); + return new ActionOperatorResult( Success: false, Exception: ex ); + } + } + + private static void CopyDirectoryRecursive( string source, string destination, bool overwrite ) { + DirectoryInfo dir = new( source ); + if (!dir.Exists) { return; } + + _ = Directory.CreateDirectory( destination ); + + foreach (FileInfo file in dir.GetFiles( )) { + string targetPath = Path.Join( destination, file.Name ); + _ = file.CopyTo( targetPath, overwrite ); + } + + foreach (DirectoryInfo subDir in dir.GetDirectories( )) { + string newDest = Path.Join( destination, subDir.Name ); + CopyDirectoryRecursive( subDir.FullName, newDest, overwrite ); + } + } +} diff --git a/src/Werkr.Agent/Operators/Actions/RenameFileHandler.cs b/src/Werkr.Agent/Operators/Actions/RenameFileHandler.cs new file mode 100644 index 0000000..d9883da --- /dev/null +++ b/src/Werkr.Agent/Operators/Actions/RenameFileHandler.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Core.Security; + +namespace Werkr.Agent.Operators.Actions; + +/// +/// Handles the RenameFile action — renames a file or directory in place. +/// +public sealed class RenameFileHandler : IActionHandler { + + private readonly IFilePathResolver _resolver; + private readonly ILogger _logger; + + /// Creates a new . + public RenameFileHandler( IFilePathResolver resolver, ILogger logger ) { + _resolver = resolver; + _logger = logger; + } + + /// + public string Action => "RenameFile"; + + /// + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + try { + RenameFileParameters p = parameters.Deserialize( ActionJson.SerializerOptions ) + ?? throw new ArgumentException( "Failed to deserialize RenameFile parameters." ); + + string fullPath = _resolver.ResolveSinglePath( p.Path ); + + if (Directory.Exists( fullPath )) { + // Rename directory + DirectoryInfo dir = new( fullPath ); + string updatePath = dir.Parent == null + ? p.NewName + : Path.Join( dir.Parent.FullName, p.NewName ); + + _ = _resolver.ResolveSinglePath( updatePath ); + + if (Directory.Exists( updatePath )) { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Warning, $"Destination directory '{updatePath}' already exists." ), + cancellationToken ); + return new ActionOperatorResult( Success: false ); + } + + Directory.Move( dir.FullName, updatePath ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Renamed directory '{fullPath}' → '{updatePath}'" ), + cancellationToken ); + } else if (File.Exists( fullPath )) { + // Rename file + FileInfo file = new( fullPath ); + string updatePath = file.Directory == null + ? p.NewName + : Path.Join( file.Directory.FullName, p.NewName ); + + _ = _resolver.ResolveSinglePath( updatePath ); + + if (File.Exists( updatePath ) && !p.Overwrite) { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Warning, $"Destination file '{updatePath}' already exists and Overwrite is false." ), + cancellationToken ); + return new ActionOperatorResult( Success: false ); + } + + File.Move( file.FullName, updatePath, p.Overwrite ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Renamed file '{fullPath}' → '{updatePath}'" ), + cancellationToken ); + } else { + throw new InvalidOperationException( $"Source path '{fullPath}' does not exist." ); + } + + return new ActionOperatorResult( Success: true ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError( ex, "RenameFile action failed" ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Error, $"RenameFile failed: {ex.Message}" ), + cancellationToken ); + return new ActionOperatorResult( Success: false, Exception: ex ); + } + } +} diff --git a/src/Werkr.Agent/Operators/Actions/StartProcessHandler.cs b/src/Werkr.Agent/Operators/Actions/StartProcessHandler.cs new file mode 100644 index 0000000..5d4ec8b --- /dev/null +++ b/src/Werkr.Agent/Operators/Actions/StartProcessHandler.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Core.Security; + +namespace Werkr.Agent.Operators.Actions; + +/// +/// Handles the StartProcess action — starts an external process. +/// Optionally waits for the process to exit with an optional timeout. +/// +public sealed class StartProcessHandler : IActionHandler { + + private readonly IFilePathResolver _resolver; + private readonly ILogger _logger; + + /// Creates a new . + public StartProcessHandler( IFilePathResolver resolver, ILogger logger ) { + _resolver = resolver; + _logger = logger; + } + + /// + public string Action => "StartProcess"; + + /// + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + try { + StartProcessParameters p = parameters.Deserialize( ActionJson.SerializerOptions ) + ?? throw new ArgumentException( "Failed to deserialize StartProcess parameters." ); + + // Validate FileName only when it's a rooted path. Bare executable + // names (e.g. "dotnet") are resolved by the OS via PATH lookup, not + // by Path.GetFullPath. When allowlist enforcement is enabled, bare + // names bypass path validation — OS-level PATH security is the + // appropriate control for non-rooted executables. + if (Path.IsPathRooted( p.FileName )) { + _ = _resolver.ResolveSinglePath( p.FileName ); + } + + if (p.WorkingDirectory != null) { + _ = _resolver.ResolveSinglePath( p.WorkingDirectory ); + } + + ProcessStartInfo startInfo = new( ) { + FileName = p.FileName, + Arguments = p.Arguments ?? string.Empty, + WorkingDirectory = p.WorkingDirectory ?? string.Empty, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + using Process process = new( ) { StartInfo = startInfo, EnableRaisingEvents = true }; + + // Event-based async read pattern — prevents deadlock when the + // process produces more output than the pipe buffer (~4KB). + // Matches the proven SystemShellOperator pattern. + process.OutputDataReceived += ( _, e ) => { + if (e.Data is not null) { + _ = output.TryWrite( OperatorOutput.Create( LogLevel.Information, e.Data ) ); + } + }; + + process.ErrorDataReceived += ( _, e ) => { + if (e.Data is not null) { + _ = output.TryWrite( OperatorOutput.Create( LogLevel.Error, e.Data ) ); + } + }; + + if (!process.Start( )) { + throw new InvalidOperationException( $"Failed to start process '{p.FileName}'." ); + } + + process.BeginOutputReadLine( ); + process.BeginErrorReadLine( ); + + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Started process '{p.FileName}' (PID: {process.Id})" ), + cancellationToken ); + + if (p.WaitForExit) { + if (p.TimeoutMs.HasValue) { + bool exited = await WaitForExitWithTimeout( process, p.TimeoutMs.Value, cancellationToken ); + if (!exited) { + process.Kill( entireProcessTree: true ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Warning, + $"Process '{p.FileName}' (PID: {process.Id}) timed out after {p.TimeoutMs}ms and was killed." ), + cancellationToken ); + return new ActionOperatorResult( Success: false ); + } + } else { + await process.WaitForExitAsync( cancellationToken ); + } + + // Synchronous WaitForExit() (no arguments) ensures all buffered + // OutputDataReceived/ErrorDataReceived events have been delivered. + // WaitForExitAsync returns when the process exits, but async + // output events may still be in-flight. + process.WaitForExit( ); + + int exitCode = process.ExitCode; + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Process exited with code {exitCode}" ), + cancellationToken ); + + return new ActionOperatorResult( Success: exitCode == 0 ); + } + + // Fire and forget — process started but not awaited + return new ActionOperatorResult( Success: true ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError( ex, "StartProcess action failed" ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Error, $"StartProcess failed: {ex.Message}" ), + cancellationToken ); + return new ActionOperatorResult( Success: false, Exception: ex ); + } + } + + private static async Task WaitForExitWithTimeout( + Process process, int timeoutMs, CancellationToken cancellationToken ) { + using CancellationTokenSource timeoutCts = new( timeoutMs ); + using CancellationTokenSource linkedCts = + CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, timeoutCts.Token ); + + try { + await process.WaitForExitAsync( linkedCts.Token ); + return true; + } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { + return false; + } + } +} diff --git a/src/Werkr.Agent/Operators/Actions/StopProcessHandler.cs b/src/Werkr.Agent/Operators/Actions/StopProcessHandler.cs new file mode 100644 index 0000000..97acf28 --- /dev/null +++ b/src/Werkr.Agent/Operators/Actions/StopProcessHandler.cs @@ -0,0 +1,92 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; + +namespace Werkr.Agent.Operators.Actions; + +/// +/// Handles the StopProcess action — stops a running process by name or PID. +/// Optionally force-kills the process. +/// +public sealed class StopProcessHandler : IActionHandler { + + private readonly ILogger _logger; + + /// Creates a new . + public StopProcessHandler( ILogger logger ) { + _logger = logger; + } + + /// + public string Action => "StopProcess"; + + /// + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + try { + StopProcessParameters p = parameters.Deserialize( ActionJson.SerializerOptions ) + ?? throw new ArgumentException( "Failed to deserialize StopProcess parameters." ); + + if (p.ProcessId.HasValue) { + // Stop by PID + Process process = Process.GetProcessById( p.ProcessId.Value ); + string processName = process.ProcessName; + + if (p.Force) { + process.Kill( entireProcessTree: true ); + } else { + _ = process.CloseMainWindow( ); + } + + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, + $"Stopped process '{processName}' (PID: {p.ProcessId.Value})" ), + cancellationToken ); + } else { + // Stop by name + Process[] processes = Process.GetProcessesByName( p.ProcessName ); + + if (processes.Length == 0) { + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Warning, + $"No processes found with name '{p.ProcessName}'." ), + cancellationToken ); + return new ActionOperatorResult( Success: false ); + } + + foreach (Process process in processes) { + cancellationToken.ThrowIfCancellationRequested( ); + try { + int pid = process.Id; + if (p.Force) { + process.Kill( entireProcessTree: true ); + } else { + _ = process.CloseMainWindow( ); + } + + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, + $"Stopped process '{p.ProcessName}' (PID: {pid})" ), + cancellationToken ); + } finally { + process.Dispose( ); + } + } + } + + return new ActionOperatorResult( Success: true ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError( ex, "StopProcess action failed" ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Error, $"StopProcess failed: {ex.Message}" ), + cancellationToken ); + return new ActionOperatorResult( Success: false, Exception: ex ); + } + } +} diff --git a/src/Werkr.Agent/Operators/Actions/TestExistsHandler.cs b/src/Werkr.Agent/Operators/Actions/TestExistsHandler.cs new file mode 100644 index 0000000..5379ff0 --- /dev/null +++ b/src/Werkr.Agent/Operators/Actions/TestExistsHandler.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Core.Security; + +namespace Werkr.Agent.Operators.Actions; + +/// +/// Handles the TestExists action — tests whether a file or directory exists. +/// Uses to discriminate between file, directory, or any. +/// +public sealed class TestExistsHandler : IActionHandler { + + private readonly IFilePathResolver _resolver; + private readonly ILogger _logger; + + /// Creates a new . + public TestExistsHandler( IFilePathResolver resolver, ILogger logger ) { + _resolver = resolver; + _logger = logger; + } + + /// + public string Action => "TestExists"; + + /// + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + try { + TestExistsParameters p = parameters.Deserialize( ActionJson.SerializerOptions ) + ?? throw new ArgumentException( "Failed to deserialize TestExists parameters." ); + + string fullPath = _resolver.ResolveSinglePath( p.Path ); + + bool exists = p.Type switch { + PathType.File => File.Exists( fullPath ), + PathType.Directory => Directory.Exists( fullPath ), + PathType.Any => File.Exists( fullPath ) || Directory.Exists( fullPath ), + _ => throw new ArgumentOutOfRangeException( nameof( p.Type ), p.Type, "Unknown PathType value." ) + }; + + string typeLabel = p.Type.ToString( ).ToLowerInvariant( ); + string status = exists ? "exists" : "does not exist"; + + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"TestExists({typeLabel}): '{fullPath}' {status}" ), + cancellationToken ); + + // Success is true when the path exists, false when it does not. + return new ActionOperatorResult( Success: exists ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError( ex, "TestExists action failed" ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Error, $"TestExists failed: {ex.Message}" ), + cancellationToken ); + return new ActionOperatorResult( Success: false, Exception: ex ); + } + } +} diff --git a/src/Werkr.Agent/Operators/Actions/WriteContentHandler.cs b/src/Werkr.Agent/Operators/Actions/WriteContentHandler.cs new file mode 100644 index 0000000..c2c72ce --- /dev/null +++ b/src/Werkr.Agent/Operators/Actions/WriteContentHandler.cs @@ -0,0 +1,63 @@ +using System.Text; +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Core.Security; + +namespace Werkr.Agent.Operators.Actions; + +/// +/// Handles the WriteContent action — writes or appends text content to a file. +/// +public sealed class WriteContentHandler : IActionHandler { + + private readonly IFilePathResolver _resolver; + private readonly ILogger _logger; + + /// Creates a new . + public WriteContentHandler( IFilePathResolver resolver, ILogger logger ) { + _resolver = resolver; + _logger = logger; + } + + /// + public string Action => "WriteContent"; + + /// + public async Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken ) { + try { + WriteContentParameters p = parameters.Deserialize( ActionJson.SerializerOptions ) + ?? throw new ArgumentException( "Failed to deserialize WriteContent parameters." ); + + string fullPath = _resolver.ResolveSinglePath( p.Path ); + + Encoding encoding = Encoding.GetEncoding( p.Encoding ); + + if (p.Append) { + await File.AppendAllTextAsync( fullPath, p.Content, encoding, cancellationToken ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Appended content to '{fullPath}'" ), + cancellationToken ); + } else { + await File.WriteAllTextAsync( fullPath, p.Content, encoding, cancellationToken ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Information, $"Wrote content to '{fullPath}'" ), + cancellationToken ); + } + + return new ActionOperatorResult( Success: true ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError( ex, "WriteContent action failed" ); + await output.WriteAsync( + OperatorOutput.Create( LogLevel.Error, $"WriteContent failed: {ex.Message}" ), + cancellationToken ); + return new ActionOperatorResult( Success: false, Exception: ex ); + } + } +} diff --git a/src/Werkr.Agent/Operators/PwshOperator.cs b/src/Werkr.Agent/Operators/PwshOperator.cs new file mode 100644 index 0000000..03ffea9 --- /dev/null +++ b/src/Werkr.Agent/Operators/PwshOperator.cs @@ -0,0 +1,255 @@ +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Text; +using System.Threading.Channels; + +using Microsoft.Extensions.Options; + +using Werkr.Common.Configuration; +using Werkr.Core.Communication; +using Werkr.Core.Operators; + +namespace Werkr.Agent.Operators; + +/// +/// PowerShell operator — executes PowerShell commands and scripts using the PowerShell SDK. +/// Uses a custom to route all output (formatting cmdlets, +/// Write-Host, Write-Error, etc.) through a single +/// path. A new is created per +/// invocation for clean isolation. +/// +/// Creates a new . +/// Agent settings containing PowerShell configuration. +/// Logger for diagnostics. +public class PwshOperator( + IOptions agentSettingsOptions, + ILogger logger ) : IShellOperator { + + private readonly int _bufferWidth = agentSettingsOptions.Value.PowerShell.BufferWidth; + + /// + public bool IsAvailable => true; + + /// + public OperatorExecution RunCommand( string command, CancellationToken cancellationToken = default ) { + Guid callId = Guid.NewGuid( ); + Channel channel = Channel.CreateBounded( + new BoundedChannelOptions( 10_000 ) { FullMode = BoundedChannelFullMode.Wait, SingleWriter = false } ); + + TaskCompletionSource resultTcs = new( TaskCreationOptions.RunContinuationsAsynchronously ); + _ = ExecuteCommandInternal( command, callId, channel.Writer, resultTcs, cancellationToken ); + return new OperatorExecution( channel.Reader.ReadAllAsync( cancellationToken ), resultTcs.Task ); + } + + /// + public OperatorExecution RunScript( string scriptPath, CancellationToken cancellationToken = default ) { + if (!File.Exists( scriptPath )) { + Guid errorCallId = Guid.NewGuid( ); + Channel errorChannel = Channel.CreateBounded( + new BoundedChannelOptions( 10 ) { FullMode = BoundedChannelFullMode.Wait, SingleWriter = true } ); + FileNotFoundException ex = new( $"Script file not found: {scriptPath}", scriptPath ); + _ = WriteErrorAndComplete( errorChannel.Writer, errorCallId, ex.Message ); + return new OperatorExecution( + errorChannel.Reader.ReadAllAsync( cancellationToken ), + Task.FromResult( new PwshOperatorResult( HadErrors: true, LastExitCode: null, Exception: ex ) ) ); + } + + string script = File.ReadAllText( scriptPath ); + return RunCommand( script, cancellationToken ); + } + + /// + public OperatorExecution RunScriptWithArgs( string scriptPath, IEnumerable args, CancellationToken cancellationToken = default ) { + if (!File.Exists( scriptPath )) { + Guid errorCallId = Guid.NewGuid( ); + Channel errorChannel = Channel.CreateBounded( + new BoundedChannelOptions( 10 ) { FullMode = BoundedChannelFullMode.Wait, SingleWriter = true } ); + FileNotFoundException ex = new( $"Script file not found: {scriptPath}", scriptPath ); + _ = WriteErrorAndComplete( errorChannel.Writer, errorCallId, ex.Message ); + return new OperatorExecution( + errorChannel.Reader.ReadAllAsync( cancellationToken ), + Task.FromResult( new PwshOperatorResult( HadErrors: true, LastExitCode: null, Exception: ex ) ) ); + } + + Guid callId = Guid.NewGuid( ); + Channel channel = Channel.CreateBounded( + new BoundedChannelOptions( 10_000 ) { FullMode = BoundedChannelFullMode.Wait, SingleWriter = false } ); + + TaskCompletionSource resultTcs = new( TaskCreationOptions.RunContinuationsAsynchronously ); + _ = ExecuteScriptWithArgsInternal( scriptPath, args, callId, channel.Writer, resultTcs, cancellationToken ); + return new OperatorExecution( channel.Reader.ReadAllAsync( cancellationToken ), resultTcs.Task ); + } + + private async Task ExecuteCommandInternal( + string command, + Guid callId, + ChannelWriter writer, + TaskCompletionSource resultTcs, + CancellationToken cancellationToken ) { + + Runspace? runspace = null; + try { + await writer.WriteAsync( OperatorOutput.Create( "Debug", $"Begin CallId: {callId}" ), cancellationToken ); + + WerkrPSHost host = new( writer, _bufferWidth ); + runspace = RunspaceFactory.CreateRunspace( host, InitialSessionState.CreateDefault( ) ); + runspace.Open( ); + + using PowerShell pwsh = PowerShell.Create( ); + pwsh.Runspace = runspace; + + // AddScript + Out-Default routes ALL output through the custom PSHost UI. + // The PSDataCollection results will be empty — everything flows + // through WerkrPSHostUserInterface → ChannelWriter. + _ = pwsh.AddScript( command, useLocalScope: true ); + _ = pwsh.AddCommand( "Out-Default" ); + + // Register cancellation callback + using CancellationTokenRegistration registration = cancellationToken.Register( ( ) => pwsh.Stop( ) ); + + // Invoke — all output is routed through the host UI + _ = await pwsh.InvokeAsync( ).WaitAsync( cancellationToken ); + + // Non-terminating errors (Write-Error) accumulate in the error stream + // but are NOT rendered through the host UI in SDK InvokeAsync context. + // Capture them explicitly post-invocation. + if (pwsh.HadErrors) { + foreach (ErrorRecord error in pwsh.Streams.Error) { + await writer.WriteAsync( + OperatorOutput.Create( "Error", FormatErrorRecord( error ) ), + cancellationToken ); + } + } + + // Extract $LASTEXITCODE if available + int? lastExitCode = ExtractLastExitCode( pwsh ); + + await writer.WriteAsync( OperatorOutput.Create( "Debug", $"End CallId: {callId}" ), cancellationToken ); + + _ = resultTcs.TrySetResult( new PwshOperatorResult( + HadErrors: pwsh.HadErrors, + LastExitCode: lastExitCode ) ); + } catch (OperationCanceledException ex) { + await writer.WriteAsync( OperatorOutput.Create( "Warning", $"Cancelled CallId: {callId}" ), CancellationToken.None ); + _ = resultTcs.TrySetResult( new PwshOperatorResult( + HadErrors: true, + LastExitCode: null, + Exception: ex ) ); + } catch (Exception ex) { + logger.LogError( ex, "PowerShell execution failed for CallId {CallId}.", callId ); + await writer.WriteAsync( OperatorOutput.Create( "Error", ex.ToString( ) ), CancellationToken.None ); + _ = resultTcs.TrySetResult( new PwshOperatorResult( + HadErrors: true, + LastExitCode: null, + Exception: ex ) ); + } finally { + runspace?.Dispose( ); + writer.Complete( ); + } + } + + private async Task ExecuteScriptWithArgsInternal( + string scriptPath, + IEnumerable args, + Guid callId, + ChannelWriter writer, + TaskCompletionSource resultTcs, + CancellationToken cancellationToken ) { + + Runspace? runspace = null; + try { + await writer.WriteAsync( OperatorOutput.Create( "Debug", $"Begin CallId: {callId}" ), cancellationToken ); + + string script = await File.ReadAllTextAsync( scriptPath, cancellationToken ); + + WerkrPSHost host = new( writer, _bufferWidth ); + runspace = RunspaceFactory.CreateRunspace( host, InitialSessionState.CreateDefault( ) ); + runspace.Open( ); + + using PowerShell pwsh = PowerShell.Create( ); + pwsh.Runspace = runspace; + + _ = pwsh.AddScript( script, useLocalScope: true ); + _ = pwsh.AddParameters( args.ToArray( ) ); + _ = pwsh.AddCommand( "Out-Default" ); + + using CancellationTokenRegistration registration = cancellationToken.Register( ( ) => pwsh.Stop( ) ); + + // Invoke — all output is routed through the host UI + _ = await pwsh.InvokeAsync( ).WaitAsync( cancellationToken ); + + // Non-terminating errors — capture from error stream post-invocation + if (pwsh.HadErrors) { + foreach (ErrorRecord error in pwsh.Streams.Error) { + await writer.WriteAsync( + OperatorOutput.Create( "Error", FormatErrorRecord( error ) ), + cancellationToken ); + } + } + + int? lastExitCode = ExtractLastExitCode( pwsh ); + + await writer.WriteAsync( OperatorOutput.Create( "Debug", $"End CallId: {callId}" ), cancellationToken ); + + _ = resultTcs.TrySetResult( new PwshOperatorResult( + HadErrors: pwsh.HadErrors, + LastExitCode: lastExitCode ) ); + } catch (OperationCanceledException ex) { + await writer.WriteAsync( OperatorOutput.Create( "Warning", $"Cancelled CallId: {callId}" ), CancellationToken.None ); + _ = resultTcs.TrySetResult( new PwshOperatorResult( + HadErrors: true, + LastExitCode: null, + Exception: ex ) ); + } catch (Exception ex) { + logger.LogError( ex, "PowerShell script execution failed for CallId {CallId}.", callId ); + await writer.WriteAsync( OperatorOutput.Create( "Error", ex.ToString( ) ), CancellationToken.None ); + _ = resultTcs.TrySetResult( new PwshOperatorResult( + HadErrors: true, + LastExitCode: null, + Exception: ex ) ); + } finally { + runspace?.Dispose( ); + writer.Complete( ); + } + } + + /// + /// Extracts the $LASTEXITCODE variable from the PowerShell session, if set. + /// Returns null when no native command was invoked. + /// + private static int? ExtractLastExitCode( PowerShell pwsh ) { + try { + object? lastExitCodeObj = pwsh.Runspace.SessionStateProxy + .GetVariable( "LASTEXITCODE" ); + if (lastExitCodeObj is int code) { + return code; + } + } catch { + // Runspace may be closed or unavailable + } + return null; + } + + private static string FormatErrorRecord( ErrorRecord error ) { + StringBuilder sb = new( ); + _ = sb.AppendLine( error.Exception?.Message ?? "Unknown error" ); + if (error.InvocationInfo is not null) { + _ = sb.AppendLine( $" Script: {error.InvocationInfo.ScriptName}" ); + _ = sb.AppendLine( $" Line: {error.InvocationInfo.ScriptLineNumber}" ); + _ = sb.AppendLine( $" Position: {error.InvocationInfo.PositionMessage}" ); + } + + if (error.CategoryInfo is not null) { + _ = sb.AppendLine( $" Category: {error.CategoryInfo.Category}" ); + } + + return sb.ToString( ).TrimEnd( ); + } + + private static async Task WriteErrorAndComplete( ChannelWriter writer, Guid callId, string message ) { + await writer.WriteAsync( OperatorOutput.Create( "Debug", $"Begin CallId: {callId}" ) ); + await writer.WriteAsync( OperatorOutput.Create( "Error", message ) ); + await writer.WriteAsync( OperatorOutput.Create( "Debug", $"End CallId: {callId}" ) ); + writer.Complete( ); + } +} diff --git a/src/Werkr.Agent/Operators/SystemShellOperator.cs b/src/Werkr.Agent/Operators/SystemShellOperator.cs new file mode 100644 index 0000000..dfcec53 --- /dev/null +++ b/src/Werkr.Agent/Operators/SystemShellOperator.cs @@ -0,0 +1,157 @@ +using System.Diagnostics; +using System.Threading.Channels; + +using Werkr.Core.Communication; +using Werkr.Core.Operators; + +namespace Werkr.Agent.Operators; + +/// +/// System shell operator — executes cmd.exe (Windows) or /bin/bash (Linux/macOS) commands and scripts. +/// Captures stdout/stderr via with backpressure support. +/// Returns a after execution completes. +/// +/// Creates a new . +/// Logger for diagnostics. +public class SystemShellOperator( ILogger logger ) : IShellOperator { + + /// + public bool IsAvailable => OperatingSystem.IsWindows( ) || OperatingSystem.IsLinux( ) || OperatingSystem.IsMacOS( ); + + /// + public OperatorExecution RunCommand( string command, CancellationToken cancellationToken = default ) { + Guid callId = Guid.NewGuid( ); + Channel channel = Channel.CreateBounded( + new BoundedChannelOptions( 10_000 ) { FullMode = BoundedChannelFullMode.Wait, SingleWriter = false } ); + + TaskCompletionSource resultTcs = new( TaskCreationOptions.RunContinuationsAsynchronously ); + _ = ExecuteCommandInternal( command, callId, channel.Writer, resultTcs, cancellationToken ); + return new OperatorExecution( channel.Reader.ReadAllAsync( cancellationToken ), resultTcs.Task ); + } + + /// + public OperatorExecution RunScript( string scriptPath, CancellationToken cancellationToken = default ) { + if (!File.Exists( scriptPath )) { + Guid errorCallId = Guid.NewGuid( ); + Channel errorChannel = Channel.CreateBounded( + new BoundedChannelOptions( 10 ) { FullMode = BoundedChannelFullMode.Wait, SingleWriter = true } ); + FileNotFoundException ex = new( $"Script file not found: {scriptPath}", scriptPath ); + _ = WriteErrorAndComplete( errorChannel.Writer, errorCallId, ex.Message ); + return new OperatorExecution( + errorChannel.Reader.ReadAllAsync( cancellationToken ), + Task.FromResult( new ShellOperatorResult( ExitCode: -1, Exception: ex ) ) ); + } + + return RunCommand( scriptPath, cancellationToken ); + } + + /// + public OperatorExecution RunScriptWithArgs( string scriptPath, IEnumerable args, CancellationToken cancellationToken = default ) { + if (!File.Exists( scriptPath )) { + Guid errorCallId = Guid.NewGuid( ); + Channel errorChannel = Channel.CreateBounded( + new BoundedChannelOptions( 10 ) { FullMode = BoundedChannelFullMode.Wait, SingleWriter = true } ); + FileNotFoundException ex = new( $"Script file not found: {scriptPath}", scriptPath ); + _ = WriteErrorAndComplete( errorChannel.Writer, errorCallId, ex.Message ); + return new OperatorExecution( + errorChannel.Reader.ReadAllAsync( cancellationToken ), + Task.FromResult( new ShellOperatorResult( ExitCode: -1, Exception: ex ) ) ); + } + + string command = $"\"{scriptPath}\" {string.Join( ' ', args )}"; + return RunCommand( command, cancellationToken ); + } + + private async Task ExecuteCommandInternal( + string command, + Guid callId, + ChannelWriter writer, + TaskCompletionSource resultTcs, + CancellationToken cancellationToken ) { + + try { + await writer.WriteAsync( OperatorOutput.Create( "Debug", $"Begin CallId: {callId}" ), cancellationToken ); + + // Determine platform shell + string shellExe; + string shellArg; + if (OperatingSystem.IsWindows( )) { + shellExe = "cmd.exe"; + shellArg = "/C"; + } else { + shellExe = "/bin/bash"; + shellArg = "-c"; + } + + using Process process = new( ) { + StartInfo = new ProcessStartInfo { + FileName = shellExe, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetTempPath( ) + }, + EnableRaisingEvents = true + }; + + process.StartInfo.ArgumentList.Add( shellArg ); + process.StartInfo.ArgumentList.Add( command ); + + process.OutputDataReceived += ( sender, e ) => { + if (e.Data is not null) { + _ = writer.TryWrite( OperatorOutput.Create( "Information", e.Data ) ); + } + }; + + process.ErrorDataReceived += ( sender, e ) => { + if (e.Data is not null) { + _ = writer.TryWrite( OperatorOutput.Create( "Error", e.Data ) ); + } + }; + + _ = process.Start( ); + process.BeginOutputReadLine( ); + process.BeginErrorReadLine( ); + + // Register cancellation callback + using CancellationTokenRegistration registration = cancellationToken.Register( ( ) => { + try { + process.Kill( entireProcessTree: true ); + } catch (InvalidOperationException) { + // Process may have already exited + } + } ); + + await process.WaitForExitAsync( cancellationToken ); + + int exitCode = process.ExitCode; + + if (exitCode != 0) { + await writer.WriteAsync( + OperatorOutput.Create( "Error", $"Exited with code {exitCode}" ), + CancellationToken.None ); + } + + await writer.WriteAsync( OperatorOutput.Create( "Debug", $"End CallId: {callId}" ), CancellationToken.None ); + + _ = resultTcs.TrySetResult( new ShellOperatorResult( ExitCode: exitCode ) ); + } catch (OperationCanceledException ex) { + await writer.WriteAsync( OperatorOutput.Create( "Warning", $"Cancelled CallId: {callId}" ), CancellationToken.None ); + _ = resultTcs.TrySetResult( new ShellOperatorResult( ExitCode: -1, Exception: ex ) ); + } catch (Exception ex) { + logger.LogError( ex, "System shell execution failed for CallId {CallId}.", callId ); + await writer.WriteAsync( OperatorOutput.Create( "Error", ex.ToString( ) ), CancellationToken.None ); + _ = resultTcs.TrySetResult( new ShellOperatorResult( ExitCode: -1, Exception: ex ) ); + } finally { + writer.Complete( ); + } + } + + private static async Task WriteErrorAndComplete( ChannelWriter writer, Guid callId, string message ) { + await writer.WriteAsync( OperatorOutput.Create( "Debug", $"Begin CallId: {callId}" ) ); + await writer.WriteAsync( OperatorOutput.Create( "Error", message ) ); + await writer.WriteAsync( OperatorOutput.Create( "Debug", $"End CallId: {callId}" ) ); + writer.Complete( ); + } +} diff --git a/src/Werkr.Agent/Operators/WerkrPSHost.cs b/src/Werkr.Agent/Operators/WerkrPSHost.cs new file mode 100644 index 0000000..07adc0c --- /dev/null +++ b/src/Werkr.Agent/Operators/WerkrPSHost.cs @@ -0,0 +1,64 @@ +using System.Globalization; +using System.Management.Automation.Host; +using System.Threading.Channels; + +using Werkr.Core.Communication; + +namespace Werkr.Agent.Operators; + +/// +/// Minimal implementation for headless PowerShell SDK hosting. +/// Provides a that routes all output +/// (Write-Host, Format-Table, Write-Error, etc.) into a +/// for streaming to the server and console UI. +/// +/// +/// Non-interactive: and +/// throw . +/// +public sealed class WerkrPSHost : PSHost { + private readonly Guid _instanceId = Guid.NewGuid( ); + private readonly WerkrPSHostUserInterface _ui; + + /// Creates a new . + /// Channel to write operator output into. + /// Column width for the formatting subsystem. Default 150. + public WerkrPSHost( ChannelWriter writer, int bufferWidth = 150 ) { + _ui = new WerkrPSHostUserInterface( writer, bufferWidth ); + } + + /// + public override string Name => "WerkrPSHost"; + + /// + public override Version Version => new( 1, 0, 0 ); + + /// + public override Guid InstanceId => _instanceId; + + /// + public override CultureInfo CurrentCulture => CultureInfo.CurrentCulture; + + /// + public override CultureInfo CurrentUICulture => CultureInfo.CurrentUICulture; + + /// + public override PSHostUserInterface UI => _ui; + + /// + public override void SetShouldExit( int exitCode ) { /* no-op */ } + + /// + public override void EnterNestedPrompt( ) + => throw new NotSupportedException( "Non-interactive host." ); + + /// + public override void ExitNestedPrompt( ) + => throw new NotSupportedException( "Non-interactive host." ); + + /// + public override void NotifyBeginApplication( ) { /* no-op */ } + + /// + public override void NotifyEndApplication( ) { /* no-op */ } +} diff --git a/src/Werkr.Agent/Operators/WerkrPSHostRawUserInterface.cs b/src/Werkr.Agent/Operators/WerkrPSHostRawUserInterface.cs new file mode 100644 index 0000000..7a30212 --- /dev/null +++ b/src/Werkr.Agent/Operators/WerkrPSHostRawUserInterface.cs @@ -0,0 +1,104 @@ +using System.Management.Automation.Host; + +namespace Werkr.Agent.Operators; + +/// +/// Provides buffer dimensions for PowerShell formatting cmdlets. +/// Sets width to a configurable value (default 150) so that +/// Format-Table and Format-List produce correctly wrapped columnar output +/// instead of single-line or raw FormatEntryData type names. +/// +/// +/// All setter properties are no-ops — the headless agent has no real console. +/// Input methods throw (non-interactive). +/// +public sealed class WerkrPSHostRawUserInterface : PSHostRawUserInterface { + private readonly Size _bufferSize; + + /// Creates a new with the specified buffer width. + /// Column width for formatting cmdlets. Default 150. + public WerkrPSHostRawUserInterface( int bufferWidth = 150 ) { + _bufferSize = new Size( bufferWidth, 50 ); + } + + /// + public override Size BufferSize { + get => _bufferSize; + set { /* no-op — headless host */ } + } + + /// + public override Size WindowSize { + get => _bufferSize; + set { /* no-op */ } + } + + /// + public override Size MaxWindowSize => _bufferSize; + + /// + public override Size MaxPhysicalWindowSize => _bufferSize; + + /// + public override Coordinates WindowPosition { + get => new( 0, 0 ); + set { /* no-op */ } + } + + /// + public override Coordinates CursorPosition { + get => new( 0, 0 ); + set { /* no-op */ } + } + + /// + public override int CursorSize { + get => 25; + set { /* no-op */ } + } + + /// + public override ConsoleColor ForegroundColor { + get => ConsoleColor.White; + set { /* no-op */ } + } + + /// + public override ConsoleColor BackgroundColor { + get => ConsoleColor.Black; + set { /* no-op */ } + } + + /// + public override string WindowTitle { + get => "Werkr Agent"; + set { /* no-op */ } + } + + /// + public override bool KeyAvailable => false; + + /// + public override KeyInfo ReadKey( ReadKeyOptions options ) + => throw new NotSupportedException( "Non-interactive host." ); + + /// + public override void FlushInputBuffer( ) { /* no-op */ } + + /// + public override void SetBufferContents( Coordinates origin, BufferCell[,] contents ) + => throw new NotSupportedException( "Non-interactive host." ); + + /// + public override void SetBufferContents( Rectangle rectangle, BufferCell fill ) + => throw new NotSupportedException( "Non-interactive host." ); + + /// + public override BufferCell[,] GetBufferContents( Rectangle rectangle ) + => throw new NotSupportedException( "Non-interactive host." ); + + /// + public override void ScrollBufferContents( + Rectangle source, Coordinates destination, Rectangle clip, BufferCell fill ) + => throw new NotSupportedException( "Non-interactive host." ); +} diff --git a/src/Werkr.Agent/Operators/WerkrPSHostUserInterface.cs b/src/Werkr.Agent/Operators/WerkrPSHostUserInterface.cs new file mode 100644 index 0000000..de2625e --- /dev/null +++ b/src/Werkr.Agent/Operators/WerkrPSHostUserInterface.cs @@ -0,0 +1,101 @@ +using System.Collections.ObjectModel; +using System.Management.Automation; +using System.Management.Automation.Host; +using System.Security; +using System.Threading.Channels; + +using Werkr.Common.Rendering; +using Werkr.Core.Communication; + +namespace Werkr.Agent.Operators; + +/// +/// Routes PowerShell host UI output (Write-Host, Format-Table, etc.) +/// into the operator's for streaming. +/// This is the single output path for all PowerShell output — stream +/// DataAdded handlers are not used. +/// +/// +/// Non-interactive: all input/prompt methods throw . +/// +public sealed class WerkrPSHostUserInterface : PSHostUserInterface { + private readonly ChannelWriter _writer; + private readonly WerkrPSHostRawUserInterface _rawUI; + + /// Creates a new . + /// Channel to write operator output into. + /// Column width for the raw UI buffer. + public WerkrPSHostUserInterface( ChannelWriter writer, int bufferWidth ) { + _writer = writer; + _rawUI = new WerkrPSHostRawUserInterface( bufferWidth ); + } + + /// + public override PSHostRawUserInterface RawUI => _rawUI; + + /// + public override void Write( string value ) + => _writer.TryWrite( OperatorOutput.Create( "Information", value ) ); + + /// + public override void Write( ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value ) { + string fgAnsi = AnsiHtmlConverter.ConsoleColorToAnsi( foregroundColor ); + string bgAnsi = AnsiHtmlConverter.ConsoleColorToBgAnsi( backgroundColor ); + string encoded = $"{fgAnsi}{bgAnsi}{value}{AnsiHtmlConverter.AnsiReset}"; + _ = _writer.TryWrite( OperatorOutput.Create( "Information", encoded ) ); + } + + /// + public override void WriteLine( string value ) + => _writer.TryWrite( OperatorOutput.Create( "Information", value ) ); + + /// + public override void WriteDebugLine( string message ) + => _writer.TryWrite( OperatorOutput.Create( "Debug", message ) ); + + /// + public override void WriteVerboseLine( string message ) + => _writer.TryWrite( OperatorOutput.Create( "Verbose", message ) ); + + /// + public override void WriteWarningLine( string message ) + => _writer.TryWrite( OperatorOutput.Create( "Warning", message ) ); + + /// + public override void WriteErrorLine( string message ) + => _writer.TryWrite( OperatorOutput.Create( "Error", message ) ); + + /// + public override void WriteProgress( long sourceId, ProgressRecord record ) + => _writer.TryWrite( OperatorOutput.Create( "Progress", + $"{record.Activity}: {record.StatusDescription} ({record.PercentComplete}%)" ) ); + + /// + public override string ReadLine( ) + => throw new NotSupportedException( "Non-interactive host." ); + + /// + public override SecureString ReadLineAsSecureString( ) + => throw new NotSupportedException( "Non-interactive host." ); + + /// + public override Dictionary Prompt( + string caption, string message, Collection descriptions ) + => throw new NotSupportedException( "Non-interactive host." ); + + /// + public override PSCredential PromptForCredential( + string caption, string message, string userName, string targetName ) + => throw new NotSupportedException( "Non-interactive host." ); + + /// + public override PSCredential PromptForCredential( + string caption, string message, string userName, string targetName, + PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options ) + => throw new NotSupportedException( "Non-interactive host." ); + + /// + public override int PromptForChoice( + string caption, string message, Collection choices, int defaultChoice ) + => throw new NotSupportedException( "Non-interactive host." ); +} diff --git a/src/Werkr.Agent/Program.cs b/src/Werkr.Agent/Program.cs new file mode 100644 index 0000000..3e1c1af --- /dev/null +++ b/src/Werkr.Agent/Program.cs @@ -0,0 +1,205 @@ +using System.Runtime.InteropServices; +using System.Threading.Channels; +using Microsoft.EntityFrameworkCore; +using Serilog; +using Serilog.Settings.Configuration; +using Werkr.Agent.Communication; +using Werkr.Agent.Interceptors; +using Werkr.Agent.Operators; +using Werkr.Agent.Registration; +using Werkr.Agent.Scheduling; +using Werkr.Agent.Security; +using Werkr.Agent.Services; +using Werkr.Common; +using Werkr.Common.Configuration; +using Werkr.Common.Extensions; +using Werkr.Common.Models; +using Werkr.Core.Cryptography; +using Werkr.Core.Operators; +using Werkr.Core.Security; +using Werkr.Core.Tasks; +using Werkr.Data; +using Werkr.ServiceDefaults; + +namespace Werkr.Agent; + +/// Application entry point for the Werkr Agent. +public class Program { + /// Main entry point. + /// Command-line arguments. + public static async Task Main( string[] args ) { + Log.Logger = new LoggerConfiguration( ) + .WriteTo.Console( ) + .CreateBootstrapLogger( ); + + try { + Log.Information( "Starting Werkr Agent..." ); + + string version = System.Reflection.CustomAttributeExtensions + .GetCustomAttribute( + System.Reflection.Assembly.GetEntryAssembly( )! ) + ?.InformationalVersion ?? "unknown"; + Log.Information( "Werkr Agent version {Version}", version ); + + // Validate platform crypto support + EncryptionProvider.ValidatePlatformCryptoSupport( ); + + // Retrieve or generate the SQLCipher passphrase from OS secret store + ISecretStore secretStore = SecretStoreFactory.Create( ); + string? passphrase = await secretStore.GetSecretAsync( "werkr-agent-db" ); + if (passphrase is null) { + passphrase = Convert.ToHexString( EncryptionProvider.GenerateRandomBytes( 32 ) ); + await secretStore.SetSecretAsync( "werkr-agent-db", passphrase ); + Log.Information( "Generated new SQLCipher passphrase for Agent database." ); + } + + // Determine platform-appropriate data directory + string dataDir = GetDataDirectory( ); + _ = Directory.CreateDirectory( dataDir ); + string dbPath = Path.Combine( dataDir, "werkr-agent.db" ); + + WebApplicationBuilder builder = WebApplication.CreateBuilder( args ); + + _ = builder.Configuration.AddWerkrConfigPath( "Agent" ); + + // Serilog (ConfigurationReaderOptions required for single-file publish) + ConfigurationReaderOptions readerOptions = new( + typeof( Serilog.ConsoleLoggerConfigurationExtensions ).Assembly, + typeof( Serilog.FileLoggerConfigurationExtensions ).Assembly, + typeof( Serilog.Sinks.OpenTelemetry.OtlpProtocol ).Assembly ); + _ = builder.Host.UseSerilog( ( ctx, lc ) => lc + .ReadFrom.Configuration( ctx.Configuration, readerOptions ) ); + + // Aspire service defaults + _ = builder.AddServiceDefaults( ); + + // Local SQLite WerkrDbContext (unencrypted; SQLCipher removed) + string connectionString = $"Data Source={dbPath}"; + _ = builder.Services.AddWerkrDbContext( DatabaseProvider.SQLite, connectionString ); + + // OS Secret Store + _ = builder.Services.AddSingleton( secretStore ); + + // Agent settings via options pattern + _ = builder.Services.Configure( + builder.Configuration.GetSection( AgentSettings.SectionName ) ); + _ = builder.Services.Configure( + builder.Configuration.GetSection( AllowedPathsConfiguration.SectionName ) ); + _ = builder.Services.Configure( + builder.Configuration.GetSection( ActionOperatorConfiguration.SectionName ) ); + + // gRPC with BearerTokenInterceptor for authenticated access + _ = builder.Services.AddGrpc( options => { + options.Interceptors.Add( ); + } ); + + + + // Kestrel endpoint configuration. + // All endpoints use TLS with Http1AndHttp2 — ALPN negotiates HTTP/2 + // for gRPC automatically. In containers, the TLS certificate is mounted + // from the host and configured via ASPNETCORE_Kestrel__Certificates__Default__* + // environment variables. Outside containers, the dev cert handles TLS. + _ = builder.WebHost.ConfigureKestrel( options => { + options.ConfigureEndpointDefaults( listenOptions => { + listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2; + } ); + + options.Limits.Http2.KeepAlivePingDelay = TimeSpan.FromSeconds( 30 ); + options.Limits.Http2.KeepAlivePingTimeout = TimeSpan.FromSeconds( 10 ); + } ); + + // Agent-specific services + _ = builder.Services.AddSingleton( ); + _ = builder.Services.AddSingleton( ); + _ = builder.Services.AddSingleton( ); + _ = builder.Services.AddActionHandlers( ); + _ = builder.Services.AddSingleton( ); + _ = builder.Services.AddSingleton( ); + _ = builder.Services.AddScoped( ); + + // Schedule evaluation services + _ = builder.Services.AddSingleton( ); + _ = builder.Services.Configure( + builder.Configuration.GetSection( JobOutputOptions.SectionName ) ); + _ = builder.Services.AddSingleton( ); + _ = builder.Services.AddSingleton( ); + _ = builder.Services.AddSingleton( Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true } ) ); + _ = builder.Services.AddHostedService( ); + + WebApplication app = builder.Build( ); + + // Auto-migrate in development + if (app.Environment.IsDevelopment( )) { + using IServiceScope scope = app.Services.CreateScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + // Clear any stale EF Core migration lock left by a previous crash/kill. + // EF Core 9+ uses __EFMigrationsLock for distributed locking, but for a + // single-process SQLite/SQLCipher database this lock can only become stale + // (no second process will ever release it). Without this cleanup, + // MigrateAsync spins on INSERT OR IGNORE indefinitely. + try { + _ = await dbContext.Database.ExecuteSqlRawAsync( + "DELETE FROM \"__EFMigrationsLock\"" ); + } catch { + // Table may not exist on first run — safe to ignore. + } + + await dbContext.Database.MigrateAsync( ); + } + + // Map gRPC services + _ = app.MapGrpcService( ); + _ = app.MapGrpcService( ); + _ = app.MapGrpcService( ); + _ = app.MapGrpcService( ); + _ = app.MapGrpcService( ); + _ = app.MapGrpcService( ); + + // Default Aspire endpoints (health, etc.) + _ = app.MapDefaultEndpoints( ); + + // Registration endpoints (localhost-only) + _ = app.MapRegistrationEndpoints( ); + + _ = app.MapGet( "/", ( ) => "Werkr Agent is running. Communication is via gRPC." ); + + app.Run( ); + } catch (Exception ex) { + Log.Fatal( ex, "Werkr Agent terminated unexpectedly." ); + } finally { + Log.CloseAndFlush( ); + } + } + + /// + /// Returns the platform-appropriate data directory for the Agent. + /// Checks the WERKR_DATA_DIR environment variable first, then falls back + /// to platform-specific defaults. + /// + /// The absolute path to the data directory. + private static string GetDataDirectory( ) { + // Environment variable override (e.g. Docker containers, systemd services) + string? envDataDir = Environment.GetEnvironmentVariable( "WERKR_DATA_DIR" ); + if (!string.IsNullOrWhiteSpace( envDataDir )) { + return envDataDir; + } + + if (RuntimeInformation.IsOSPlatform( OSPlatform.Windows )) { + string localAppData = Environment.GetFolderPath( Environment.SpecialFolder.LocalApplicationData ); + return Path.Combine( localAppData, "Werkr", "data" ); + } + + if (RuntimeInformation.IsOSPlatform( OSPlatform.OSX )) { + string home = Environment.GetFolderPath( Environment.SpecialFolder.UserProfile ); + return Path.Combine( home, "Library", "Application Support", "Werkr", "data" ); + } + + // Linux + string dataHome = Environment.GetEnvironmentVariable( "XDG_DATA_HOME" ) + ?? Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.UserProfile ), ".local", "share" ); + return Path.Combine( dataHome, "werkr", "data" ); + } +} diff --git a/src/Werkr.Agent/Protos/Action.proto b/src/Werkr.Agent/Protos/Action.proto new file mode 100644 index 0000000..3351ca6 --- /dev/null +++ b/src/Werkr.Agent/Protos/Action.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +option csharp_namespace = "Werkr.Agent.Protos"; + +import "EncryptedEnvelope.proto"; + +// Built-in action service. +// All RPCs use EncryptedEnvelope for request/response payloads. +// Request envelope contains a serialized ActionRequest. +// Streaming response envelopes each contain a serialized GrpcLogMsg. +service Action { + rpc RunAction (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); +} + +// Inner payload message — serialized, encrypted, then placed inside EncryptedEnvelope. +// NOT used directly in the RPC signature. +message ActionRequest { + string action_name = 1; + string parameters_json = 2; +} diff --git a/src/Werkr.Agent/Protos/GrpcLogMsg.proto b/src/Werkr.Agent/Protos/GrpcLogMsg.proto new file mode 100644 index 0000000..3aa4892 --- /dev/null +++ b/src/Werkr.Agent/Protos/GrpcLogMsg.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +option csharp_namespace = "Werkr.Agent.Protos"; + +// Log message streamed from shell operations. +// Serialized to bytes, encrypted as an EncryptedEnvelope, and streamed. +// Each streaming frame is an independently encrypted envelope containing +// one serialized GrpcLogMsg. +message GrpcLogMsg { + string log_level = 1; + string message = 2; + string timestamp = 3; +} diff --git a/src/Werkr.Agent/Protos/Shell.proto b/src/Werkr.Agent/Protos/Shell.proto new file mode 100644 index 0000000..bb832a4 --- /dev/null +++ b/src/Werkr.Agent/Protos/Shell.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +option csharp_namespace = "Werkr.Agent.Protos"; + +import "EncryptedEnvelope.proto"; + +// PowerShell shell service. +// All RPCs use EncryptedEnvelope for request/response payloads. +// Request envelopes contain a serialized ShellRequest or ScriptRequest. +// Streaming response envelopes each contain a serialized GrpcLogMsg. +service Pwsh { + rpc RunCommand (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); + rpc RunScript (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); + rpc RunScriptWithArgs (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); +} + +// System shell service (cmd.exe / bash). +// All RPCs use EncryptedEnvelope for request/response payloads. +service SystemShell { + rpc RunCommand (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); + rpc RunScript (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); + rpc RunScriptWithArgs (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); +} + +// Inner payload messages — serialized, encrypted, then placed inside EncryptedEnvelope. +// These are NOT used directly in RPC signatures. +message ShellRequest { + string command = 1; +} + +message ScriptRequest { + string script = 1; + repeated string args = 2; +} diff --git a/src/Werkr.Agent/Registration/AgentRegistrationHandler.cs b/src/Werkr.Agent/Registration/AgentRegistrationHandler.cs new file mode 100644 index 0000000..2c13da7 --- /dev/null +++ b/src/Werkr.Agent/Registration/AgentRegistrationHandler.cs @@ -0,0 +1,157 @@ +using System.Security.Cryptography; +using System.Text.Json; + +using Grpc.Net.Client; + +using Werkr.Api.Protos; +using Werkr.Common.Models; +using Werkr.Core.Cryptography; +using Werkr.Core.Cryptography.KeyInfo; +using Werkr.Core.Registration.Models; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Agent.Registration; + +/// +/// Processes a registration bundle on the Agent side: decrypts the bundle, +/// generates the Agent's RSA keypair, calls the Server's gRPC RegisterAgent +/// endpoint, and persists the resulting connection to the local database. +/// +/// +/// Creates a new . +/// +/// Logger for diagnostics. +public class AgentRegistrationHandler( ILogger logger ) { + + /// + /// Processes a registration bundle pasted by the admin, generates the Agent's + /// RSA keypair, calls the Server's RegisterAgent gRPC endpoint, and persists + /// the resulting connection to the local database. + /// + /// The Base64-encoded encrypted bundle string from the Server admin. + /// The password used to encrypt the bundle. + /// The Agent's gRPC endpoint URL. + /// The Agent's local database context. + /// Cancellation token. + /// An indicating success or failure. + public async Task ProcessBundleAsync( + string encryptedBundle, + string password, + string agentUrl, + WerkrDbContext dbContext, + CancellationToken ct + ) { + + // 1. Decrypt the bundle payload + RegistrationBundlePayload payload; + try { + payload = RegistrationBundlePayload.FromEncryptedString( encryptedBundle, password ); + } catch (Exception ex) when (ex is CryptographicException or FormatException or JsonException) { + if (logger.IsEnabled( LogLevel.Warning )) { + logger.LogWarning( ex, "Failed to decrypt registration bundle. Check password and bundle integrity." ); + } + + return new AgentRegistrationResult( false, null, null, + "Failed to decrypt registration bundle. Verify the password and that the bundle was copied correctly." ); + } + + // 2. Generate Agent's own RSA keypair + RSAKeyPair agentKeyPair = EncryptionProvider.GenerateRSAKeyPair( 4096 ); + + // 3. Hybrid-encrypt Agent's public key with Server's public key + byte[] agentPublicKeyBytes = EncryptionProvider.SerializePublicKey( agentKeyPair.PublicKey ); + RSAParameters serverPublicKey = EncryptionProvider.DeserializePublicKey( payload.ServerPublicKeyBytes ); + byte[] encryptedAgentPublicKey = EncryptionProvider.HybridEncrypt( agentPublicKeyBytes, serverPublicKey ); + + // 4. Create gRPC channel and call RegisterAgent + using GrpcChannel channel = GrpcChannel.ForAddress( payload.ServerUrl ); + AgentRegistration.AgentRegistrationClient client = new( channel ); + + RegisterAgentRequest request = new( ) { + BundleId = Google.Protobuf.ByteString.CopyFrom( payload.BundleId ), + EncryptedAgentPublicKey = Google.Protobuf.ByteString.CopyFrom( encryptedAgentPublicKey ), + AgentUrl = agentUrl, + AgentName = Environment.MachineName + }; + + RegisterAgentResponse response; + try { + response = await client.RegisterAgentAsync( request, cancellationToken: ct ); + } catch (Exception ex) { + if (logger.IsEnabled( LogLevel.Error )) { + logger.LogError( ex, "gRPC call to Server's RegisterAgent endpoint failed." ); + } + + return new AgentRegistrationResult( false, null, null, + $"Failed to contact the Server at {payload.ServerUrl}. Verify the Server is running and reachable." ); + } + + if (!response.Success) { + return new AgentRegistrationResult( false, null, null, response.Message ); + } + + // 5. Hybrid-decrypt the registration response data + byte[] decryptedResponseBytes; + try { + decryptedResponseBytes = EncryptionProvider.HybridDecrypt( + response.EncryptedRegistrationData.ToByteArray( ), + agentKeyPair.PrivateKey ); + } catch (WerkrCryptoException ex) { + if (logger.IsEnabled( LogLevel.Error )) { + logger.LogError( ex, "Failed to decrypt registration response data." ); + } + + return new AgentRegistrationResult( false, null, null, + "Registration succeeded on the Server but the response could not be decrypted." ); + } + + RegistrationResponsePayload? responsePayload = JsonSerializer.Deserialize( + decryptedResponseBytes ); + if (responsePayload is null) { + return new AgentRegistrationResult( false, null, null, + "Registration succeeded but the response payload was invalid." ); + } + + // 6. Persist RegisteredConnection locally + // Use the shared ConnectionId from the Server so both sides reference the same ID + // Agent stores: OutboundApiKey = raw Agent→Server key, InboundApiKeyHash = hash of Server→Agent key + RegisteredConnection connection = new( ) { + Id = responsePayload.ConnectionId, + ConnectionName = payload.ConnectionName, + RemoteUrl = payload.ServerUrl, + LocalPublicKey = agentKeyPair.PublicKey, + LocalPrivateKey = agentKeyPair.PrivateKey, + RemotePublicKey = serverPublicKey, + OutboundApiKey = responsePayload.AgentToServerApiKey, + InboundApiKeyHash = EncryptionProvider.HashSHA512String( responsePayload.ServerToAgentApiKey ), + SharedKey = responsePayload.SharedKey, + IsServer = false, + Status = ConnectionStatus.Connected, + }; + + try { + _ = dbContext.RegisteredConnections.Add( connection ); + _ = await dbContext.SaveChangesAsync( ct ); + } catch (Exception ex) { + if (logger.IsEnabled( LogLevel.Error )) { + logger.LogError( ex, + "Registration succeeded on Server but failed to save locally for connection '{ConnectionName}'.", + payload.ConnectionName ); + } + + return new AgentRegistrationResult( false, null, null, + "Registration succeeded on the Server but failed to save locally. " + + "The admin should revoke the orphaned connection on the Server (via /agents page) and generate a new bundle." ); + } + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Successfully registered with Server at {ServerUrl} as '{ConnectionName}'.", + payload.ServerUrl, payload.ConnectionName ); + } + + return new AgentRegistrationResult( true, responsePayload.AgentToServerApiKey, responsePayload.SharedKey, + $"Registration complete. Connection '{payload.ConnectionName}' established with Server at {payload.ServerUrl}." ); + } +} diff --git a/src/Werkr.Agent/Registration/RegistrationEndpoints.cs b/src/Werkr.Agent/Registration/RegistrationEndpoints.cs new file mode 100644 index 0000000..b608f7d --- /dev/null +++ b/src/Werkr.Agent/Registration/RegistrationEndpoints.cs @@ -0,0 +1,186 @@ +using System.Text.Json; + +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; + +using Werkr.Core.Registration.Models; +using Werkr.Data; + +namespace Werkr.Agent.Registration; + +/// +/// Maps the localhost-only registration endpoints for the Agent. +/// +public static class RegistrationEndpoints { + /// + /// Maps the GET /register and POST /register endpoints, + /// restricted to localhost connections only. + /// + /// The web application to map endpoints on. + /// The for further chaining. + public static RouteGroupBuilder MapRegistrationEndpoints( this WebApplication app ) { + RouteGroupBuilder group = app.MapGroup( "/register" ) + .RequireHost( "localhost", "127.0.0.1" ); + + _ = group.MapGet( "/", HandleGetRegistrationPage ); + _ = group.MapPost( "/", HandlePostRegistration ); + + return group; + } + + /// + /// Returns a simple HTML registration page. + /// + private static IResult HandleGetRegistrationPage( IConfiguration configuration, IServer server ) { + string agentUrl = ResolveAgentUrl( configuration, server ); + + string html = $$""" + + + + + + Werkr Agent Registration + + + +

Werkr Agent Registration

+

Paste the registration bundle from your Server admin and enter the password.

+ +
+ + + + + + +
+ Agent URL +
{{agentUrl}}
+
This Agent's gRPC endpoint URL (from configuration).
+
+ + +
+ +
+ + + + +"""; + return Results.Content( html, "text/html" ); + } + + /// + /// Processes a registration bundle submitted by the admin. + /// + private static async Task HandlePostRegistration( + HttpRequest request, + IConfiguration configuration, + IServer server, + AgentRegistrationHandler handler, + WerkrDbContext dbContext, + CancellationToken ct ) { + + RegistrationRequest? body; + try { + body = await request.ReadFromJsonAsync( ct ); + } catch (JsonException) { + return Results.BadRequest( new RegistrationResponse( false, "Invalid JSON request body." ) ); + } + + if (body is null + || string.IsNullOrWhiteSpace( body.Bundle ) + || string.IsNullOrWhiteSpace( body.Password )) { + return Results.BadRequest( new RegistrationResponse( false, + "Missing required fields: bundle, password." ) ); + } + + string agentUrl = ResolveAgentUrl( configuration, server ); + + AgentRegistrationResult result = await handler.ProcessBundleAsync( + body.Bundle, body.Password, agentUrl, dbContext, ct ); + + return Results.Json( new RegistrationResponse( result.Success, result.ErrorMessage ?? string.Empty ) ); + } + + /// + /// Resolves the agent's externally-reachable gRPC URL. + /// Checks Werkr:AgentUrl configuration first; if not set, discovers + /// the bound address from (dynamic Aspire ports). + /// + private static string ResolveAgentUrl( IConfiguration configuration, IServer server ) { + string? configured = configuration.GetValue( "Werkr:AgentUrl" ); + if (!string.IsNullOrWhiteSpace( configured )) { + return configured; + } + + IServerAddressesFeature? addresses = server.Features.Get( ); + return addresses?.Addresses + .FirstOrDefault( a => a.StartsWith( "https://", StringComparison.OrdinalIgnoreCase ) ) + ?? addresses?.Addresses.FirstOrDefault( ) + ?? "https://localhost:5001"; + } + + /// JSON request body for POST /register. + /// The encrypted registration bundle string. + /// The password used to encrypt the bundle. + internal sealed record RegistrationRequest( string Bundle, string Password ); + + /// JSON response body for POST /register. + /// Whether the registration succeeded. + /// A user-facing message describing the outcome. + internal sealed record RegistrationResponse( bool Success, string Message ); +} diff --git a/src/Werkr.Agent/Scheduling/AgentJobOutputWriter.cs b/src/Werkr.Agent/Scheduling/AgentJobOutputWriter.cs new file mode 100644 index 0000000..16b8e1e --- /dev/null +++ b/src/Werkr.Agent/Scheduling/AgentJobOutputWriter.cs @@ -0,0 +1,110 @@ +using Microsoft.Extensions.Options; + +using Werkr.Common.Configuration; +using Werkr.Core.Communication; + +namespace Werkr.Agent.Scheduling; + +/// +/// Agent-local equivalent of . +/// Writes job output to individual log files on the Agent's disk. Each job gets +/// a file named {JobId}.log in the configured output directory. Output is +/// appended incrementally so partial output is preserved if the job crashes. +/// +/// Job output directory configuration. +/// Logger instance. +public sealed class AgentJobOutputWriter( IOptions options, ILogger logger ) { + + private readonly string _outputDirectory = options.Value.OutputDirectory; + private readonly int _tailPreviewLength = options.Value.TailPreviewLength; + + /// + /// Ensures the output directory exists. Called once at service startup or first use. + /// + public void EnsureDirectoryExists( ) { + if (!Directory.Exists( _outputDirectory )) { + _ = Directory.CreateDirectory( _outputDirectory ); + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Created agent job output directory: {Directory}", _outputDirectory ); + } + } + } + + /// + /// Returns the full file path for a job's output log. + /// + /// The job identifier. + /// Absolute or relative path to the log file. + public string GetOutputPath( Guid jobId ) => + Path.Combine( _outputDirectory, $"{jobId}.log" ); + + /// + /// Returns the relative file name for a job's output log (for storing in OutputPath). + /// + /// The job identifier. + /// Relative file name. + public static string GetRelativeOutputPath( Guid jobId ) => + $"{jobId}.log"; + + /// + /// Writes an operator output line to the job's log file, appending to any existing content. + /// Format: [timestamp] [level] message + /// + /// The job identifier. + /// The operator output record to write. + /// Cancellation token. + public async Task WriteLineAsync( Guid jobId, OperatorOutput output, CancellationToken ct = default ) { + EnsureDirectoryExists( ); + string filePath = GetOutputPath( jobId ); + string line = FormatLine( output ); + await File.AppendAllTextAsync( filePath, line + Environment.NewLine, ct ); + } + + /// + /// Writes multiple operator output lines to the job's log file. + /// + /// The job identifier. + /// The operator output records to write. + /// Cancellation token. + public async Task WriteLinesAsync( Guid jobId, IEnumerable outputs, CancellationToken ct = default ) { + EnsureDirectoryExists( ); + string filePath = GetOutputPath( jobId ); + IEnumerable lines = outputs.Select( FormatLine ); + await File.AppendAllLinesAsync( filePath, lines, ct ); + } + + /// + /// Reads the full output file for a job. Returns null if the file does not exist. + /// + /// The job identifier. + /// Cancellation token. + /// Full file contents, or null if no output was written. + public async Task ReadFullOutputAsync( Guid jobId, CancellationToken ct = default ) { + string filePath = GetOutputPath( jobId ); + return !File.Exists( filePath ) ? null : await File.ReadAllTextAsync( filePath, ct ); + } + + /// + /// Extracts the tail preview (last N characters) from a job's output file. + /// + /// The job identifier. + /// Cancellation token. + /// Tail preview string, or null if no output exists. + public async Task GetTailPreviewAsync( Guid jobId, CancellationToken ct = default ) { + string filePath = GetOutputPath( jobId ); + if (!File.Exists( filePath )) { + return null; + } + + string content = await File.ReadAllTextAsync( filePath, ct ); + return content.Length <= _tailPreviewLength + ? content + : content[^_tailPreviewLength..]; + } + + /// + /// Formats an record into a log line. + /// + private static string FormatLine( OperatorOutput output ) => + $"[{output.Timestamp}] [{output.LogLevel}] {output.Message}"; +} diff --git a/src/Werkr.Agent/Scheduling/ScheduleEvaluatorService.cs b/src/Werkr.Agent/Scheduling/ScheduleEvaluatorService.cs new file mode 100644 index 0000000..6babb4b --- /dev/null +++ b/src/Werkr.Agent/Scheduling/ScheduleEvaluatorService.cs @@ -0,0 +1,957 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Agent.Communication; +using Werkr.Agent.Operators; +using Werkr.Common.Models.Actions; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Core.Scheduling; +using Werkr.Core.Tasks; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Models; +using Werkr.Data.Entities.Schedule; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Agent.Scheduling; + +/// +/// Background service that evaluates schedules locally on the Agent. +/// +/// On startup (after registration), pulls assigned schedules from the Server via +/// , computes next fire times via +/// , and maintains a +/// fire queue. The timer loop sleeps until the next fire time, executes the task +/// using local operators, streams output to , +/// evaluates success via , and reports results +/// back to the Server via . +/// +/// +/// Handles invalidation pushes from the Server via , +/// triggering an immediate re-sync when schedules change. +/// +/// +/// Factory for creating outbound gRPC clients to the Server. +/// Writes job output to local disk. +/// Evaluates success criteria. +/// PowerShell operator. +/// System shell operator. +/// Built-in action operator. +/// Channel for receiving invalidation signals. +/// Logger. +public sealed class ScheduleEvaluatorService( + AgentGrpcClientFactory clientFactory, + AgentJobOutputWriter outputWriter, + SuccessCriteriaEvaluator successEvaluator, + PwshOperator pwshOperator, + SystemShellOperator shellOperator, + IActionOperator actionOperator, + Channel invalidationChannel, + ILogger logger +) : BackgroundService { + + // ── Internal state ────────────────────────────────────────────────────────── + + /// + /// Represents a scheduled item in the fire queue. + /// + internal sealed record FireQueueEntry( + DateTime FireTimeUtc, + ScheduledTaskDefinition? Task, + ScheduledWorkflowDefinition? Workflow ) : IComparable { + public int CompareTo( FireQueueEntry? other ) { + if (other is null) { + return 1; + } + + int cmp = FireTimeUtc.CompareTo( other.FireTimeUtc ); + if (cmp != 0) { + return cmp; + } + // Break ties by task/workflow ID + long thisId = Task?.TaskId ?? Workflow?.WorkflowId ?? 0; + long otherId = other.Task?.TaskId ?? other.Workflow?.WorkflowId ?? 0; + return thisId.CompareTo( otherId ); + } + } + + private readonly SortedSet _fireQueue = []; + private readonly Lock _queueLock = new( ); + + /// + /// Tracks the last sync time for each task so per-task re-sync intervals work. + /// Key: task or workflow ID (prefixed with "t:" or "w:" to avoid collisions). + /// + private readonly Dictionary _lastSyncTimes = []; + + /// + /// Stores the current assigned task/workflow definitions from the last sync. + /// + private readonly List _currentTasks = []; + private readonly List _currentWorkflows = []; + private readonly Lock _definitionsLock = new( ); + + /// + /// Cached holiday dates per schedule (populated during sync from bulk RPC). + /// Key: schedule ID, Value: (mode, holiday dates). + /// + private readonly Dictionary Dates)> _holidayCache = []; + + // ── Constants ──────────────────────────────────────────────────────────────── + + private static readonly TimeSpan s_startupDelay = TimeSpan.FromSeconds( 5 ); + private static readonly TimeSpan s_maxBackoff = TimeSpan.FromMinutes( 5 ); + private static readonly TimeSpan s_defaultTimerResolution = TimeSpan.FromSeconds( 15 ); + + // ── BackgroundService ──────────────────────────────────────────────────────── + + /// + protected override async Task ExecuteAsync( CancellationToken stoppingToken ) { + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "ScheduleEvaluatorService starting..." ); + } + + // Wait for the agent to complete registration before syncing + await WaitForRegistrationAsync( stoppingToken ); + + outputWriter.EnsureDirectoryExists( ); + + // Initial sync with exponential backoff + await SyncWithBackoffAsync( stoppingToken ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Schedule evaluator initialized. {TaskCount} tasks, {WorkflowCount} workflows in queue.", + _currentTasks.Count, _currentWorkflows.Count ); + } + + // Main evaluation loop + await RunEvaluationLoopAsync( stoppingToken ); + } + + // ── Registration Wait ──────────────────────────────────────────────────────── + + /// + /// Waits until the agent has been registered before syncing schedules. + /// Polls every 5 seconds with exponential backoff. + /// + private async Task WaitForRegistrationAsync( CancellationToken ct ) { + TimeSpan delay = s_startupDelay; + while (!ct.IsCancellationRequested) { + if (await clientFactory.IsRegisteredAsync( ct )) { + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Agent is registered. Starting schedule sync." ); + } + return; + } + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Agent not yet registered. Retrying in {Delay}.", delay ); + } + await Task.Delay( delay, ct ); + delay = TimeSpan.FromTicks( Math.Min( delay.Ticks * 2, s_maxBackoff.Ticks ) ); + } + } + + // ── Sync with Backoff ──────────────────────────────────────────────────────── + + /// + /// Pulls assigned schedules from the Server with exponential backoff on failure. + /// + private async Task SyncWithBackoffAsync( CancellationToken ct ) { + TimeSpan delay = TimeSpan.FromSeconds( 2 ); + + for (int attempt = 1; !ct.IsCancellationRequested; attempt++) { + try { + await SyncSchedulesAsync( ct ); + return; + } catch (Exception ex) when (ex is not OperationCanceledException) { + if (logger.IsEnabled( LogLevel.Warning )) { + logger.LogWarning( ex, "Schedule sync attempt {Attempt} failed. Retrying in {Delay}.", attempt, delay ); + } + await Task.Delay( delay, ct ); + delay = TimeSpan.FromTicks( Math.Min( delay.Ticks * 2, s_maxBackoff.Ticks ) ); + } + } + } + + // ── Full Schedule Sync ─────────────────────────────────────────────────────── + + /// + /// Pulls assigned schedules from the Server and rebuilds the fire queue. + /// + internal async Task SyncSchedulesAsync( CancellationToken ct ) { + ScheduleSync.ScheduleSyncClient client = await clientFactory.CreateScheduleSyncClientAsync( ct ); + Grpc.Core.CallOptions callOptions = clientFactory.CreateCallOptions( cancellationToken: ct ); + + RegisteredConnectionInfo connection = await GetConnectionInfoAsync( ct ); + + AgentScheduleRequest request = new( ) { + ConnectionId = connection.ConnectionId, + }; + request.Tags.AddRange( connection.Tags ); + + EncryptedEnvelope requestEnvelope = PayloadEncryptor.EncryptToEnvelope( + request, clientFactory.GetSharedKey( ), clientFactory.GetKeyId( ) ); + EncryptedEnvelope responseEnvelope = await client.GetAssignedSchedulesAsync( requestEnvelope, callOptions ); + AgentScheduleResponse response = PayloadEncryptor.DecryptFromEnvelope( + responseEnvelope, clientFactory.GetSharedKey( ) ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Synced {TaskCount} tasks and {WorkflowCount} workflows from server.", + response.Tasks.Count, response.Workflows.Count ); + } + + // Update cached definitions + lock (_definitionsLock) { + _currentTasks.Clear( ); + _currentTasks.AddRange( response.Tasks ); + _currentWorkflows.Clear( ); + _currentWorkflows.AddRange( response.Workflows ); + } + + // ── Bulk-fetch holiday dates for holiday-enabled schedules ── + await FetchBulkHolidayDatesAsync( response, ct ); + + // Rebuild fire queue + RebuildFireQueue( ); + + // Update sync times + DateTime now = DateTime.UtcNow; + foreach (ScheduledTaskDefinition task in response.Tasks) { + _lastSyncTimes[$"t:{task.TaskId}"] = now; + } + foreach (ScheduledWorkflowDefinition wf in response.Workflows) { + _lastSyncTimes[$"w:{wf.WorkflowId}"] = now; + } + } + + /// + /// Fetches holiday dates in bulk for all holiday-enabled schedules from the server. + /// + private async Task FetchBulkHolidayDatesAsync( AgentScheduleResponse response, CancellationToken ct ) { + _holidayCache.Clear( ); + + GetBulkScheduleHolidayDatesRequest bulkRequest = new( ) { + StartDate = DateOnly.FromDateTime( DateTime.UtcNow ).ToString( "O" ), + EndDate = DateOnly.FromDateTime( DateTime.UtcNow.AddHours( 24 ) ).ToString( "O" ), + }; + + // Collect holiday-enabled schedule IDs from tasks and workflows + foreach (ScheduledTaskDefinition task in response.Tasks) { + if (task.Schedule is not null && task.Schedule.HasHolidayCalendar) { + bulkRequest.Queries.Add( new ScheduleHolidayDateQuery { ScheduleId = task.Schedule.ScheduleId } ); + } + } + foreach (ScheduledWorkflowDefinition wf in response.Workflows) { + if (wf.Schedule is not null && wf.Schedule.HasHolidayCalendar) { + bulkRequest.Queries.Add( new ScheduleHolidayDateQuery { ScheduleId = wf.Schedule.ScheduleId } ); + } + } + + if (bulkRequest.Queries.Count == 0) { + return; + } + + try { + ScheduleSync.ScheduleSyncClient client = await clientFactory.CreateScheduleSyncClientAsync( ct ); + Grpc.Core.CallOptions callOptions = clientFactory.CreateCallOptions( cancellationToken: ct ); + + EncryptedEnvelope reqEnvelope = PayloadEncryptor.EncryptToEnvelope( + bulkRequest, clientFactory.GetSharedKey( ), clientFactory.GetKeyId( ) ); + EncryptedEnvelope resEnvelope = await client.GetBulkScheduleHolidayDatesAsync( reqEnvelope, callOptions ); + GetBulkScheduleHolidayDatesResponse bulkResponse = PayloadEncryptor.DecryptFromEnvelope( + resEnvelope, clientFactory.GetSharedKey( ) ); + + foreach (ScheduleHolidayDateResult result in bulkResponse.Results) { + if (!Guid.TryParse( result.ScheduleId, out Guid scheduleId )) { + continue; + } + + if (!Enum.TryParse( result.Mode, out HolidayCalendarMode mode )) { + continue; + } + + List dates = [.. result.Dates.Select( d => new HolidayDate { + Date = DateOnly.Parse( d.Date ), + Name = d.Name, + Year = d.Year, + WindowStart = !string.IsNullOrEmpty( d.WindowStart ) ? TimeOnly.Parse( d.WindowStart ) : null, + WindowEnd = !string.IsNullOrEmpty( d.WindowEnd ) ? TimeOnly.Parse( d.WindowEnd ) : null, + WindowTimeZoneId = !string.IsNullOrEmpty( d.WindowTimeZoneId ) ? d.WindowTimeZoneId : null, + } )]; + + _holidayCache[scheduleId] = (mode, dates); + } + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Fetched holiday data for {Count} schedules.", _holidayCache.Count ); + } + } catch (Exception ex) { + logger.LogWarning( ex, "Failed to fetch bulk holiday dates. Schedules will run without holiday filtering." ); + } + } + + // ── Fire Queue Management ──────────────────────────────────────────────────── + + /// + /// Rebuilds the fire queue from current definitions. + /// Calculates next occurrence for each task/workflow using . + /// + internal void RebuildFireQueue( ) { + lock (_queueLock) { + _fireQueue.Clear( ); + + DateTime now = DateTime.UtcNow; + DateTime endOfWindow = now.AddHours( 24 ); // Look ahead 24 hours + + lock (_definitionsLock) { + foreach (ScheduledTaskDefinition task in _currentTasks) { + if (task.Schedule is null) { + continue; + } + + try { + Schedule schedule = MapProtoToSchedule( task.Schedule ); + DateTime? next = CalculateNextWithHolidays( schedule, now, endOfWindow ); + if (next.HasValue && next.Value != default) { + _ = _fireQueue.Add( new FireQueueEntry( next.Value, task, null ) ); + } + } catch (Exception ex) { + logger.LogWarning( ex, "Failed to calculate occurrences for task {TaskId} '{TaskName}'.", + task.TaskId, task.Name ); + } + } + + foreach (ScheduledWorkflowDefinition workflow in _currentWorkflows) { + if (workflow.Schedule is null) { + continue; + } + + try { + Schedule schedule = MapProtoToSchedule( workflow.Schedule ); + DateTime? next = CalculateNextWithHolidays( schedule, now, endOfWindow ); + if (next.HasValue && next.Value != default) { + _ = _fireQueue.Add( new FireQueueEntry( next.Value, null, workflow ) ); + } + } catch (Exception ex) { + logger.LogWarning( ex, "Failed to calculate occurrences for workflow {WorkflowId} '{WorkflowName}'.", + workflow.WorkflowId, workflow.Name ); + } + } + } + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Fire queue rebuilt: {Count} entries. Next fire: {NextFire}.", + _fireQueue.Count, + _fireQueue.Count > 0 ? _fireQueue.Min!.FireTimeUtc.ToString( "o" ) : "none" ); + } + } + } + + // ── Evaluation Loop ────────────────────────────────────────────────────────── + + /// + /// Main timer loop. Waits until the next fire time, executes the task or + /// delegates the workflow, then advances the queue. Also listens for + /// invalidation signals and per-task re-sync intervals. + /// + private async Task RunEvaluationLoopAsync( CancellationToken ct ) { + while (!ct.IsCancellationRequested) { + try { + // Check for invalidation signals (non-blocking drain) + await DrainInvalidationChannelAsync( ct ); + + // Check per-task re-sync intervals + await CheckPerTaskReSyncAsync( ct ); + + // Determine sleep duration + TimeSpan sleepDuration; + lock (_queueLock) { + if (_fireQueue.Count > 0) { + TimeSpan untilNext = _fireQueue.Min!.FireTimeUtc - DateTime.UtcNow; + sleepDuration = untilNext > TimeSpan.Zero + ? (untilNext < s_defaultTimerResolution ? untilNext : s_defaultTimerResolution) + : TimeSpan.Zero; + } else { + sleepDuration = s_defaultTimerResolution; + } + } + + if (sleepDuration > TimeSpan.Zero) { + // Use a combined wait: sleep or invalidation signal, whichever comes first + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( ct ); + Task sleepTask = Task.Delay( sleepDuration, linked.Token ); + Task invalidationTask = WaitForInvalidationAsync( linked.Token ); + + _ = await Task.WhenAny( sleepTask, invalidationTask ); + await linked.CancelAsync( ); + } + + // Fire any due entries + await FireDueEntriesAsync( ct ); + } catch (OperationCanceledException) when (ct.IsCancellationRequested) { + break; + } catch (Exception ex) { + logger.LogError( ex, "Unexpected error in schedule evaluation loop. Continuing..." ); + await Task.Delay( TimeSpan.FromSeconds( 5 ), ct ); + } + } + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "ScheduleEvaluatorService stopped." ); + } + } + + /// + /// Waits for a single invalidation signal on the channel. + /// + private async Task WaitForInvalidationAsync( CancellationToken ct ) { + try { + _ = await invalidationChannel.Reader.ReadAsync( ct ); + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Invalidation signal received. Will re-sync." ); + } + await SyncWithBackoffAsync( ct ); + RebuildFireQueue( ); + } catch (OperationCanceledException) { + // Expected — the linked token was cancelled + } + } + + /// + /// Drains all pending invalidation signals without blocking. + /// + private async Task DrainInvalidationChannelAsync( CancellationToken ct ) { + bool hadInvalidations = false; + while (invalidationChannel.Reader.TryRead( out string? scheduleId )) { + hadInvalidations = true; + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Drained invalidation for ScheduleId={ScheduleId}.", scheduleId ); + } + } + + if (hadInvalidations) { + await SyncWithBackoffAsync( ct ); + RebuildFireQueue( ); + } + } + + /// + /// Checks if any tasks are due for periodic re-sync based on their SyncIntervalMinutes. + /// + private async Task CheckPerTaskReSyncAsync( CancellationToken ct ) { + DateTime now = DateTime.UtcNow; + bool needsReSync = false; + + lock (_definitionsLock) { + foreach (ScheduledTaskDefinition task in _currentTasks) { + string key = $"t:{task.TaskId}"; + if (_lastSyncTimes.TryGetValue( key, out DateTime lastSync )) { + if (now - lastSync > TimeSpan.FromMinutes( task.SyncIntervalMinutes )) { + needsReSync = true; + break; + } + } + } + } + + if (needsReSync) { + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Periodic re-sync interval reached. Re-syncing schedules." ); + } + await SyncWithBackoffAsync( ct ); + RebuildFireQueue( ); + } + } + + /// + /// Fires all entries in the queue whose fire time is at or before now. + /// Tasks are executed locally; workflows are delegated to the Server. + /// + private async Task FireDueEntriesAsync( CancellationToken ct ) { + List dueEntries = []; + + lock (_queueLock) { + DateTime now = DateTime.UtcNow; + while (_fireQueue.Count > 0 && _fireQueue.Min!.FireTimeUtc <= now) { + dueEntries.Add( _fireQueue.Min ); + _ = _fireQueue.Remove( _fireQueue.Min ); + } + } + + foreach (FireQueueEntry entry in dueEntries) { + if (ct.IsCancellationRequested) { + break; + } + + try { + if (entry.Task is not null) { + await ExecuteTaskLocallyAsync( entry.Task, ct ); + } else if (entry.Workflow is not null) { + await DelegateWorkflowAsync( entry.Workflow, ct ); + } + } catch (Exception ex) { + string itemName = entry.Task?.Name ?? entry.Workflow?.Name ?? "unknown"; + logger.LogError( ex, "Failed to execute scheduled item '{ItemName}'.", itemName ); + } + + // Re-enqueue with next occurrence + ReEnqueueAfterExecution( entry ); + } + } + + /// + /// After executing a fired entry, calculates the next occurrence and adds it back to the queue. + /// + private void ReEnqueueAfterExecution( FireQueueEntry entry ) { + ScheduleDefinition? scheduleDef = entry.Task?.Schedule ?? entry.Workflow?.Schedule; + if (scheduleDef is null) { + return; + } + + try { + Schedule schedule = MapProtoToSchedule( scheduleDef ); + DateTime now = DateTime.UtcNow; + DateTime endOfWindow = now.AddHours( 24 ); + DateTime? next = CalculateNextWithHolidays( schedule, now, endOfWindow ); + + if (next.HasValue && next.Value != default) { + lock (_queueLock) { + _ = _fireQueue.Add( new FireQueueEntry( next.Value, entry.Task, entry.Workflow ) ); + } + } + } catch (Exception ex) { + string itemName = entry.Task?.Name ?? entry.Workflow?.Name ?? "unknown"; + logger.LogWarning( ex, "Failed to re-enqueue '{ItemName}' after execution.", itemName ); + } + } + + /// + /// Calculates the next occurrence using holiday-aware overload when data is cached. + /// Also enqueues audit log submission for any suppressed occurrences. + /// + private DateTime? CalculateNextWithHolidays( Schedule schedule, DateTime now, DateTime endOfWindow ) { + Guid scheduleId = schedule.DbSchedule.Id; + + if (_holidayCache.TryGetValue( scheduleId, out (HolidayCalendarMode Mode, IReadOnlyList Dates) cached )) { + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, endOfWindow, cached.Dates, cached.Mode ); + + // Enqueue audit log for suppressed occurrences (fire-and-forget) + if (result.Suppressed.Count > 0) { + _ = Task.Run( ( ) => SubmitAuditLogAsync( scheduleId, result.Suppressed, CancellationToken.None ) ); + } + + return result.Occurrences.FirstOrDefault( o => o > now ) is var next && next != default + ? next : null; + } + + // No holiday data — fall back to basic calculation + IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, endOfWindow ); + return occurrences.FirstOrDefault( o => o > now ) is var n && n != default ? n : null; + } + + /// + /// Submits suppressed occurrence audit log entries to the server. + /// + private async Task SubmitAuditLogAsync( + Guid scheduleId, + IReadOnlyList suppressed, + CancellationToken ct ) { + try { + ScheduleSync.ScheduleSyncClient client = await clientFactory.CreateScheduleSyncClientAsync( ct ); + Grpc.Core.CallOptions callOptions = clientFactory.CreateCallOptions( cancellationToken: ct ); + + SubmitAuditLogRequest request = new( ) { + ScheduleId = scheduleId.ToString( ), + }; + + foreach (SuppressedOccurrence s in suppressed) { + request.Entries.Add( new AuditLogEntry { + OccurrenceUtc = s.UtcTime.ToString( "O" ), + HolidayName = s.HolidayName, + Reason = s.Reason, + } ); + } + + EncryptedEnvelope reqEnvelope = PayloadEncryptor.EncryptToEnvelope( + request, clientFactory.GetSharedKey( ), clientFactory.GetKeyId( ) ); + _ = await client.SubmitAuditLogAsync( reqEnvelope, callOptions ); + } catch (Exception ex) { + logger.LogWarning( ex, "Failed to submit audit log for schedule {ScheduleId}.", scheduleId ); + } + } + + // ── Local Task Execution ───────────────────────────────────────────────────── + + /// + /// Executes a scheduled task locally using the appropriate operator. + /// Streams output to disk, evaluates success criteria, and reports the result + /// back to the Server. + /// + internal async Task ExecuteTaskLocallyAsync( ScheduledTaskDefinition taskDef, CancellationToken ct ) { + Guid jobId = Guid.NewGuid( ); + DateTime startTime = DateTime.UtcNow; + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Executing task {TaskId} '{TaskName}' as job {JobId}.", + taskDef.TaskId, taskDef.Name, jobId ); + } + + TaskActionType actionType = (TaskActionType) taskDef.ActionType; + List collectedOutput = []; + int exitCode = 0; + Exception? executionException = null; + ErrorCategory errorCategory = ErrorCategory.None; + + try { + // Apply timeout if configured + using CancellationTokenSource timeoutCts = taskDef.TimeoutMinutes > 0 + ? CancellationTokenSource.CreateLinkedTokenSource( ct ) + : CancellationTokenSource.CreateLinkedTokenSource( ct ); + + if (taskDef.TimeoutMinutes > 0) { + timeoutCts.CancelAfter( TimeSpan.FromMinutes( taskDef.TimeoutMinutes ) ); + } + + OperatorExecution execution = RunOperator( taskDef, actionType, timeoutCts.Token ); + + // Stream output to disk + await foreach (OperatorOutput output in execution.Output.WithCancellation( timeoutCts.Token )) { + await outputWriter.WriteLineAsync( jobId, output, timeoutCts.Token ); + collectedOutput.Add( output ); + } + + // Await the typed result + IOperatorResult result = await execution.Result; + exitCode = result switch { + ShellOperatorResult shell => shell.ExitCode, + PwshOperatorResult pwsh => pwsh.LastExitCode ?? (pwsh.HadErrors ? 1 : 0), + _ => result.Success ? 0 : 1, + }; + executionException = result.Exception; + + if (!result.Success && errorCategory == ErrorCategory.None) { + errorCategory = ErrorCategory.ScriptError; + } + } catch (OperationCanceledException) when (ct.IsCancellationRequested) { + throw; // Propagate shutdown + } catch (OperationCanceledException) { + // Timeout + errorCategory = ErrorCategory.Timeout; + exitCode = -1; + OperatorOutput timeoutMsg = OperatorOutput.Create( "Error", + $"Task '{taskDef.Name}' exceeded timeout of {taskDef.TimeoutMinutes} minutes." ); + await outputWriter.WriteLineAsync( jobId, timeoutMsg, ct ); + collectedOutput.Add( timeoutMsg ); + } catch (Exception ex) { + executionException = ex; + errorCategory = ErrorCategory.ScriptError; + exitCode = -1; + OperatorOutput errorMsg = OperatorOutput.Create( "Error", $"Execution error: {ex.Message}" ); + await outputWriter.WriteLineAsync( jobId, errorMsg, ct ); + collectedOutput.Add( errorMsg ); + } + + DateTime endTime = DateTime.UtcNow; + + // Evaluate success criteria + bool success = successEvaluator.Evaluate( + actionType, + string.IsNullOrWhiteSpace( taskDef.SuccessCriteria ) ? null : taskDef.SuccessCriteria, + exitCode, + collectedOutput, + executionException ); + + // Build tail preview for the server (matches ad-hoc job behavior) + string? tailPreview = await outputWriter.GetTailPreviewAsync( jobId, ct ); + + // Report result to server + await ReportJobResultAsync( jobId, taskDef, startTime, endTime, success, exitCode, errorCategory, null, tailPreview, ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Job {JobId} for task '{TaskName}' completed: success={Success}, exitCode={ExitCode}, duration={Duration}.", + jobId, taskDef.Name, success, exitCode, (endTime - startTime).ToString( ) ); + } + } + + /// + /// Selects and invokes the appropriate operator based on the task's . + /// + private OperatorExecution RunOperator( ScheduledTaskDefinition taskDef, TaskActionType actionType, CancellationToken ct ) { + if (actionType == TaskActionType.Action) { + using JsonDocument parsedParameters = JsonDocument.Parse( taskDef.ActionParametersJson ); + ActionDescriptor descriptor = new( ) { + Action = taskDef.ActionSubType, + Parameters = parsedParameters.RootElement.Clone( ), + }; + + return actionOperator.Execute( descriptor, ct ); + } + + IShellOperator operator_ = actionType switch { + TaskActionType.PowerShellCommand or TaskActionType.PowerShellScript => pwshOperator, + TaskActionType.ShellCommand or TaskActionType.ShellScript => shellOperator, + _ => throw new NotSupportedException( $"ActionType '{actionType}' is not supported for local execution." ), + }; + + return actionType switch { + TaskActionType.PowerShellCommand or TaskActionType.ShellCommand => + operator_.RunCommand( taskDef.Content, ct ), + + TaskActionType.PowerShellScript or TaskActionType.ShellScript when taskDef.Arguments.Count > 0 => + operator_.RunScriptWithArgs( taskDef.Content, taskDef.Arguments, ct ), + + TaskActionType.PowerShellScript or TaskActionType.ShellScript => + operator_.RunScript( taskDef.Content, ct ), + + _ => throw new NotSupportedException( $"ActionType '{actionType}' is not supported for local execution." ), + }; + } + + // ── Workflow Delegation ────────────────────────────────────────────────────── + + /// + /// Delegates workflow execution to the Server. Only the Server can orchestrate + /// multi-agent workflows because it has visibility across all agents and connectivity. + /// + private async Task DelegateWorkflowAsync( ScheduledWorkflowDefinition workflow, CancellationToken ct ) { + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Delegating workflow {WorkflowId} '{WorkflowName}' to server.", + workflow.WorkflowId, workflow.Name ); + } + + try { + WorkflowExecution.WorkflowExecutionClient client = + await clientFactory.CreateWorkflowExecutionClientAsync( ct ); + Grpc.Core.CallOptions callOptions = clientFactory.CreateCallOptions( cancellationToken: ct ); + + RegisteredConnectionInfo connection = await GetConnectionInfoAsync( ct ); + + WorkflowRunGrpcRequest innerRequest = new( ) { + WorkflowId = workflow.WorkflowId, + ConnectionId = connection.ConnectionId, + }; + EncryptedEnvelope requestEnvelope = PayloadEncryptor.EncryptToEnvelope( + innerRequest, clientFactory.GetSharedKey( ), clientFactory.GetKeyId( ) ); + EncryptedEnvelope responseEnvelope = await client.RequestWorkflowRunAsync( + requestEnvelope, callOptions ); + WorkflowRunGrpcResponse response = PayloadEncryptor.DecryptFromEnvelope( + responseEnvelope, clientFactory.GetSharedKey( ) ); + + if (response.Accepted) { + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Workflow {WorkflowId} accepted. RunId={RunId}.", + workflow.WorkflowId, response.WorkflowRunId ); + } + } else { + logger.LogWarning( "Workflow {WorkflowId} rejected by server: {Message}.", + workflow.WorkflowId, response.Message ); + } + } catch (Exception ex) { + logger.LogError( ex, "Failed to delegate workflow {WorkflowId} '{WorkflowName}' to server.", + workflow.WorkflowId, workflow.Name ); + } + } + + // ── Job Reporting ──────────────────────────────────────────────────────────── + + /// Maximum number of retry attempts for reporting a job result. + private const int ReportMaxRetries = 3; + + /// Initial delay between retry attempts. + private static readonly TimeSpan s_reportRetryBaseDelay = TimeSpan.FromSeconds( 2 ); + + /// + /// Reports a completed job result to the Server via gRPC with retry on transient failures. + /// + private async Task ReportJobResultAsync( + Guid jobId, + ScheduledTaskDefinition taskDef, + DateTime startTime, + DateTime endTime, + bool success, + int exitCode, + ErrorCategory errorCategory, + string? workflowRunId, + string? outputPreview, + CancellationToken ct ) { + + JobReporting.JobReportingClient client = await clientFactory.CreateJobReportingClientAsync( ct ); + RegisteredConnectionInfo connection = await GetConnectionInfoAsync( ct ); + + JobResultRequest innerRequest = new( ) { + ConnectionId = connection.ConnectionId, + TaskId = taskDef.TaskId, + TaskSnapshot = taskDef.Content, + RuntimeSeconds = ( endTime - startTime ).TotalSeconds, + StartTime = startTime.ToString( "o" ), + EndTime = endTime.ToString( "o" ), + Success = success, + ExitCode = exitCode, + ErrorCategory = (int) errorCategory, + OutputPath = AgentJobOutputWriter.GetRelativeOutputPath( jobId ), + }; + if (!string.IsNullOrWhiteSpace( workflowRunId )) { + innerRequest.WorkflowRunId = workflowRunId; + } + if (!string.IsNullOrWhiteSpace( outputPreview )) { + innerRequest.OutputPreview = outputPreview; + } + + EncryptedEnvelope requestEnvelope = PayloadEncryptor.EncryptToEnvelope( + innerRequest, clientFactory.GetSharedKey( ), clientFactory.GetKeyId( ) ); + + TimeSpan delay = s_reportRetryBaseDelay; + for (int attempt = 1; attempt <= ReportMaxRetries; attempt++) { + try { + Grpc.Core.CallOptions callOptions = clientFactory.CreateCallOptions( cancellationToken: ct ); + EncryptedEnvelope responseEnvelope = await client.ReportJobResultAsync( requestEnvelope, callOptions ); + JobResultResponse response = PayloadEncryptor.DecryptFromEnvelope( + responseEnvelope, clientFactory.GetSharedKey( ) ); + + if (response.Accepted) { + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Job result reported. Server JobId={ServerJobId}.", response.JobId ); + } + } else { + logger.LogWarning( "Server rejected job result for task {TaskId}.", taskDef.TaskId ); + } + return; // Success — exit retry loop + } catch (Exception ex) when (attempt < ReportMaxRetries && !ct.IsCancellationRequested) { + logger.LogWarning( ex, + "Failed to report job result for task {TaskId} (attempt {Attempt}/{MaxRetries}). Retrying in {Delay}.", + taskDef.TaskId, attempt, ReportMaxRetries, delay ); + await Task.Delay( delay, ct ); + delay *= 2; // Exponential backoff + } catch (Exception ex) { + logger.LogError( ex, "Failed to report job result for task {TaskId} after {MaxRetries} attempts.", + taskDef.TaskId, ReportMaxRetries ); + } + } + } + + // ── Proto ↔ Schedule Mapping ───────────────────────────────────────────────── + + /// + /// Maps a proto to a composite model + /// for use with . + /// + internal static Schedule MapProtoToSchedule( ScheduleDefinition def ) { + TimeZoneInfo startTz = !string.IsNullOrWhiteSpace( def.TimeZoneId ) + ? TimeZoneInfo.FindSystemTimeZoneById( def.TimeZoneId ) + : TimeZoneInfo.Utc; + + DateTime startLocal = ParseDateAndTime( def.StartDate, def.StartTime ); + + StartDateTimeInfo startDt = new( ) { + Date = DateOnly.FromDateTime( startLocal ), + Time = TimeOnly.FromDateTime( startLocal ), + TimeZone = startTz, + }; + + // Parse schedule ID + if (Guid.TryParse( def.ScheduleId, out Guid scheduleId )) { + startDt.ScheduleId = scheduleId; + } + + ExpirationDateTimeInfo? expiration = null; + if (!string.IsNullOrWhiteSpace( def.ExpirationDate )) { + TimeZoneInfo expTz = !string.IsNullOrWhiteSpace( def.ExpirationTimeZoneId ) + ? TimeZoneInfo.FindSystemTimeZoneById( def.ExpirationTimeZoneId ) + : startTz; + DateTime expLocal = ParseDateAndTime( def.ExpirationDate, def.ExpirationTime ); + expiration = new( ) { + Date = DateOnly.FromDateTime( expLocal ), + Time = TimeOnly.FromDateTime( expLocal ), + TimeZone = expTz, + }; + if (Guid.TryParse( def.ScheduleId, out Guid expSchId )) { + expiration.ScheduleId = expSchId; + } + } + + DailyRecurrence? daily = def.Daily is not null && def.Daily.DayInterval > 0 + ? new( ) { DayInterval = def.Daily.DayInterval } + : null; + + WeeklyRecurrence? weekly = def.Weekly is not null && def.Weekly.WeekInterval > 0 + ? new( ) { + WeekInterval = def.Weekly.WeekInterval, + DaysOfWeek = (DaysOfWeek) def.Weekly.RecurrenceDays, + } + : null; + + MonthlyRecurrence? monthly = null; + if (def.Monthly is not null && (def.Monthly.DayNumbers.Count > 0 || def.Monthly.DaysOfWeek > 0)) { + monthly = new( ) { + MonthsOfYear = (MonthsOfYear)def.Monthly.MonthsOfYear, + WeekNumber = (WeekNumberWithinMonth)def.Monthly.WeekNumber, + DaysOfWeek = (DaysOfWeek)def.Monthly.DaysOfWeek, + }; + if (def.Monthly.DayNumbers.Count > 0) { + monthly.DayNumbers = [.. def.Monthly.DayNumbers]; + } + } + + ScheduleRepeatOptions? repeat = def.Repeat is not null && def.Repeat.IntervalMinutes > 0 + ? new( ) { + RepeatIntervalMinutes = def.Repeat.IntervalMinutes, + RepeatDurationMinutes = def.Repeat.DurationMinutes, + } + : null; + + Schedule schedule = new( ) { + DbSchedule = new DbSchedule { + Id = Guid.TryParse( def.ScheduleId, out Guid sid ) ? sid : Guid.Empty, + StopTaskAfterMinutes = def.StopTaskAfterMinutes, + }, + StartDateTime = startDt, + Expiration = expiration, + DailyRecurrence = daily, + WeeklyRecurrence = weekly, + MonthlyRecurrence = monthly, + RepeatOptions = repeat, + }; + + // Holiday calendar metadata (lightweight — actual dates are in _holidayCache) + if (def.HasHolidayCalendar) { + schedule.HolidayCalendar = new HolidayCalendar { Name = "(via proto)" }; + if (Enum.TryParse( def.HolidayCalendarMode, out HolidayCalendarMode parsedMode )) { + schedule.HolidayCalendarMode = parsedMode; + } + } + + return schedule; + } + + /// + /// Parses date and time strings from proto definitions into a . + /// + private static DateTime ParseDateAndTime( string date, string time ) { + DateOnly parsedDate = DateOnly.Parse( date ); + TimeOnly parsedTime = !string.IsNullOrWhiteSpace( time ) + ? TimeOnly.Parse( time ) + : TimeOnly.MinValue; + return parsedDate.ToDateTime( parsedTime ); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + /// + /// Helper record for connection info needed by gRPC calls. + /// + private sealed record RegisteredConnectionInfo( string ConnectionId, IReadOnlyList Tags ); + + /// + /// Gets the agent's connection info from the cached connection. + /// + private async Task GetConnectionInfoAsync( CancellationToken ct ) { + Data.Entities.Registration.RegisteredConnection connection = await clientFactory.GetConnectionAsync( ct ); + return new RegisteredConnectionInfo( + connection.Id.ToString( ), + connection.Tags ?? [] ); + } +} diff --git a/src/Werkr.Agent/Security/FilePathResolver.cs b/src/Werkr.Agent/Security/FilePathResolver.cs new file mode 100644 index 0000000..f52849b --- /dev/null +++ b/src/Werkr.Agent/Security/FilePathResolver.cs @@ -0,0 +1,64 @@ +using System.Runtime.InteropServices; + +using Werkr.Core.Security; + +namespace Werkr.Agent.Security; + +/// +/// Resolves and validates file-system paths against the configured allowlist. +/// Provides shared wildcard resolution and source/destination validation used +/// by all built-in action handlers. Delegates path validation to +/// . +/// +public sealed class FilePathResolver : IFilePathResolver { + + private readonly IPathAllowlistValidator _pathValidator; + private readonly StringComparison _comparison; + + /// Creates a new . + public FilePathResolver( IPathAllowlistValidator pathValidator ) { + _pathValidator = pathValidator; + _comparison = RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + } + + /// + public string ResolveSinglePath( string path ) { + string fullPath = Path.GetFullPath( path ); + _pathValidator.ValidatePath( fullPath ); + return fullPath; + } + + /// + public string[] ResolveFiles( string source ) { + FileInfo fileInfo = new( source ); + string dirName = fileInfo.DirectoryName + ?? throw new InvalidOperationException( "Source must be rooted under a directory." ); + + if (!Directory.Exists( dirName )) { + return []; + } + + string[] files = Directory.GetFiles( dirName, fileInfo.Name ); + + // Validate each resolved file individually against the allowlist. + // This closes the glob-security gap where a wildcard inside an allowed + // directory could match symlinks pointing outside the allowlist. + foreach (string file in files) { + _pathValidator.ValidatePath( file ); + } + + return files; + } + + /// + public void ValidateSourceDestination( string source, string destination ) { + string fullSource = Path.GetFullPath( source ); + string fullDestination = Path.GetFullPath( destination ); + + if (fullSource.Equals( fullDestination, _comparison )) { + throw new ArgumentException( "Source path cannot be the same as the Destination path." ); + } + } +} diff --git a/src/Werkr.Agent/Security/NativeMethods.cs b/src/Werkr.Agent/Security/NativeMethods.cs new file mode 100644 index 0000000..8eb066f --- /dev/null +++ b/src/Werkr.Agent/Security/NativeMethods.cs @@ -0,0 +1,79 @@ +using System.Runtime.InteropServices; + +namespace Werkr.Agent.Security; + +/// +/// Platform-specific native methods for file path resolution. +/// Contains the GetLongPathNameW P/Invoke for expanding 8.3 short file +/// names on Windows. On non-Windows platforms, all methods are safe no-ops that +/// return the input path unchanged. +/// +internal static partial class NativeMethods { + + /// + /// Expands 8.3 short file name segments (e.g. PROGRA~1) to their + /// long-name equivalents (e.g. Program Files) on Windows. + /// Returns the original unchanged when: + /// + /// Running on a non-Windows platform (8.3 names do not exist). + /// The Win32 call fails (e.g. path does not exist on disk). + /// No 8.3 segments are present (expanded path equals input). + /// + /// + /// The file-system path to expand. + /// The expanded long path, or the original path if expansion is unnecessary or unavailable. + internal static string GetLongPath( string path ) { + return !RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ? path : GetLongPathWindows( path ); + } + + /// + /// Windows-specific implementation using the two-call pattern: + /// first call retrieves the required buffer size, second call + /// performs the actual expansion. + /// + private static string GetLongPathWindows( string path ) { + // First call: determine required buffer size. + // Pass buffer length 0 to get the required size (including null terminator). + uint requiredSize = GetLongPathNameW( path, null, 0 ); + + if (requiredSize == 0) { + // GetLongPathNameW returns 0 on failure (e.g. path does not exist). + // Fall back to the original path — the caller will proceed with + // whatever normalization Path.GetFullPath already applied. + return path; + } + + // Second call: allocate exact buffer and retrieve the long path. + char[] buffer = new char[requiredSize]; + uint written = GetLongPathNameW( path, buffer, requiredSize ); + + if (written == 0 || written > requiredSize) { + // Unexpected failure or buffer overflow — fall back gracefully. + return path; + } + + return new string( buffer, 0, (int)written ); + } + + /// + /// Retrieves the long path form of the specified path. + /// + /// The path to expand. + /// + /// A buffer that receives the long path form. May be null when + /// is 0 (to query the required size). + /// + /// + /// The size of the buffer, in characters. + /// + /// + /// If the call succeeds and the buffer is large enough, the number of + /// characters copied (excluding the null terminator). If the buffer is too + /// small, the required size (including the null terminator). Returns 0 on failure. + /// + [LibraryImport( "kernel32.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = true )] + private static partial uint GetLongPathNameW( + string lpszShortPath, + [Out] char[]? lpszLongPath, + uint cchBuffer ); +} diff --git a/src/Werkr.Agent/Security/PathAllowlistValidator.cs b/src/Werkr.Agent/Security/PathAllowlistValidator.cs new file mode 100644 index 0000000..b78d7e8 --- /dev/null +++ b/src/Werkr.Agent/Security/PathAllowlistValidator.cs @@ -0,0 +1,153 @@ +using System.Runtime.InteropServices; +using Microsoft.Extensions.Options; + +using Werkr.Common.Models; +using Werkr.Core.Security; + +namespace Werkr.Agent.Security; + +/// +/// Validates that file paths are within the configured allowlist. +/// When enforcement is disabled (the default), all paths are permitted. +/// When enforcement is enabled, paths outside all allowed prefixes are rejected. +/// +/// +/// Uses for hot-reload support — if the +/// configuration changes at runtime, the validator picks up the new values +/// on the next call without requiring a restart. +/// +public sealed class PathAllowlistValidator : IPathAllowlistValidator { + + private readonly IOptionsMonitor _options; + private readonly ILogger _logger; + private readonly StringComparison _comparison; + + /// Creates a new . + public PathAllowlistValidator( + IOptionsMonitor options, + ILogger logger ) { + _options = options; + _logger = logger; + _comparison = RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + } + + /// + public void ValidatePath( string path ) { + if (!IsPathAllowed( path )) { + AllowedPathsConfiguration config = _options.CurrentValue; + string allowlist = string.Join( ", ", config.Paths ); + _logger.LogWarning( + "Path '{Path}' is outside the configured allowlist [{Allowlist}]", path, allowlist ); + throw new UnauthorizedAccessException( + $"Path '{path}' is outside the configured allowlist. " + + $"Allowed prefixes: [{allowlist}]" ); + } + } + + /// + public void ValidatePaths( params string[] paths ) { + foreach (string path in paths) { + ValidatePath( path ); + } + } + + /// + public bool IsPathAllowed( string path ) { + AllowedPathsConfiguration config = _options.CurrentValue; + + if (!config.EnforceAllowlist) { + return true; + } + + if (config.Paths.Count == 0) { + // Enforcement enabled but no paths configured — deny all + _logger.LogWarning( "Allowlist enforcement is enabled but no paths are configured. Denying all paths." ); + return false; + } + + string normalizedPath = NormalizePath( path ); + + // Reject dangerous path patterns + if (IsDangerousPath( normalizedPath )) { + _logger.LogWarning( "Path '{Path}' uses a dangerous pattern and was rejected.", path ); + return false; + } + + foreach (string allowedPrefix in config.Paths) { + string normalizedPrefix = NormalizePath( allowedPrefix ); + if (normalizedPath.StartsWith( normalizedPrefix, _comparison )) { + return true; + } + } + + return false; + } + + private string NormalizePath( string path ) { + // Resolve to full path and normalize separators + string fullPath = Path.GetFullPath( path ); + + // Expand 8.3 short names (e.g. PROGRA~1 → Program Files) on Windows. + // No-op on non-Windows platforms. + fullPath = NativeMethods.GetLongPath( fullPath ); + + // Resolve symlinks/junctions to their final target so that the allowlist + // comparison uses the real path, not the link path. + try { + FileSystemInfo? resolved = File.ResolveLinkTarget( fullPath, returnFinalTarget: true ); + if (resolved is not null) { + fullPath = resolved.FullName; + } + } catch (IOException ex) { + // Target may not exist yet (create-before-write scenarios). + // Fall back to the best-available resolved path. + if (_logger.IsEnabled( LogLevel.Debug )) { + _logger.LogDebug( ex, "Could not resolve symlink target for '{Path}'. Using normalized path.", fullPath ); + } + } + + // Normalize directory separators to the platform-native separator + fullPath = fullPath.Replace( Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar ); + + return fullPath; + } + + private static bool IsDangerousPath( string normalizedPath ) { + if (RuntimeInformation.IsOSPlatform( OSPlatform.Windows )) { + // Reject \\?\ prefix (extended-length path) + if (normalizedPath.StartsWith( @"\\?\", StringComparison.Ordinal )) { + return true; + } + + // Reject \\.\ device paths + if (normalizedPath.StartsWith( @"\\.\", StringComparison.Ordinal )) { + return true; + } + + // Reject UNC paths (\\server\share) + if (normalizedPath.StartsWith( @"\\", StringComparison.Ordinal )) { + return true; + } + + // Reject Alternate Data Streams (colon in non-drive position) + // Drive letter colon is at index 1 (e.g., "C:\") + int firstColon = normalizedPath.IndexOf( ':', StringComparison.Ordinal ); + if (firstColon > 1 || normalizedPath.IndexOf( ':', firstColon + 1 ) >= 0) { + return true; + } + } + + // Reject paths that still contain .. traversal after normalization + // (Path.GetFullPath should have resolved these, but double-check) + string[] segments = normalizedPath.Split( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar ); + foreach (string segment in segments) { + if (segment == "..") { + return true; + } + } + + return false; + } +} diff --git a/src/Werkr.Agent/Services/ActionService.cs b/src/Werkr.Agent/Services/ActionService.cs new file mode 100644 index 0000000..358d4e8 --- /dev/null +++ b/src/Werkr.Agent/Services/ActionService.cs @@ -0,0 +1,60 @@ +using System.Text.Json; + +using Grpc.Core; + +using Werkr.Agent.Protos; +using Werkr.Common.Models.Actions; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Agent.Services; + +/// +/// gRPC service implementation for built-in action operations. +/// Authenticates via BearerTokenInterceptor, decrypts +/// requests, streams encrypted output back in envelopes. +/// +/// Creates a new . +/// The action operator for dispatching built-in actions. +/// Logger for diagnostics. +public class ActionService( + IActionOperator actionOperator, + ILogger logger +) : Werkr.Agent.Protos.Action.ActionBase { + + /// + public override async Task RunAction( + EncryptedEnvelope request, + IServerStreamWriter responseStream, + ServerCallContext context ) { + + RegisteredConnection connection = GetConnection( context ); + + ActionRequest actionRequest = PayloadEncryptor.DecryptFromEnvelope( + request, connection.SharedKey ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Executing action '{ActionName}' for connection {ConnectionId}.", + actionRequest.ActionName, connection.Id.ToString( ) ); + } + + using JsonDocument parsedParameters = JsonDocument.Parse( actionRequest.ParametersJson ); + ActionDescriptor descriptor = new( ) { + Action = actionRequest.ActionName, + Parameters = parsedParameters.RootElement.Clone( ), + }; + + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + OperatorExecution execution = actionOperator.Execute( descriptor, context.CancellationToken ); + await OperatorOutputAdapter.StreamToGrpc( + execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); + } + + private static RegisteredConnection GetConnection( ServerCallContext context ) { + return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection + ? connection + : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); + } +} diff --git a/src/Werkr.Agent/Services/ConnectionManagementService.cs b/src/Werkr.Agent/Services/ConnectionManagementService.cs new file mode 100644 index 0000000..a7a72cf --- /dev/null +++ b/src/Werkr.Agent/Services/ConnectionManagementService.cs @@ -0,0 +1,224 @@ +using System.Security.Cryptography; + +using Grpc.Core; + +using Microsoft.EntityFrameworkCore; + +using Werkr.Agent.Communication; +using Werkr.Common.Models; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Agent.Services; + +/// +/// gRPC service hosted on the Agent that receives connection management +/// commands from the Server. All RPCs use . +/// Supports server URL change notifications, heartbeat, and key rotation. +/// +/// Factory for creating DI scopes to resolve . +/// +/// The whose cached channel is reset +/// when the server URL changes. +/// +/// Logger instance. +public sealed class ConnectionManagementService( + IServiceScopeFactory scopeFactory, + AgentGrpcClientFactory clientFactory, + ILogger logger +) : ConnectionManagement.ConnectionManagementBase { + + /// + /// Handles a server URL change notification from the Server. + /// Decrypts the envelope, updates the stored server URL in the Agent's local database, + /// and resets the cached gRPC channel so subsequent calls use the new URL. + /// + public override async Task NotifyServerUrlChanged( + EncryptedEnvelope request, + ServerCallContext context ) { + + RegisteredConnection connection = GetConnection( context ); + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + + NotifyServerUrlChangedRequest inner = PayloadEncryptor.DecryptFromEnvelope( + request, connection.SharedKey ); + + if (string.IsNullOrWhiteSpace( inner.NewServerUrl )) { + throw new RpcException( new Status( StatusCode.InvalidArgument, "New server URL is required." ) ); + } + + if (!Uri.TryCreate( inner.NewServerUrl, UriKind.Absolute, out Uri? parsedUri ) + || (parsedUri.Scheme != "https" && parsedUri.Scheme != "http")) { + throw new RpcException( new Status( StatusCode.InvalidArgument, + "New server URL must be a valid HTTP or HTTPS URL." ) ); + } + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Received server URL change notification. New URL: {NewUrl}", + inner.NewServerUrl ); + } + + try { + using IServiceScope scope = scopeFactory.CreateScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + // Agent-side connection records have IsServer == false + RegisteredConnection? localConnection = await dbContext.RegisteredConnections + .FirstOrDefaultAsync( + c => !c.IsServer && c.Status == ConnectionStatus.Connected, + context.CancellationToken ); + + if (localConnection is null) { + logger.LogWarning( "No active server connection found to update." ); + NotifyServerUrlChangedResponse failResponse = new( ) { + Acknowledged = false, + Message = "No active server connection found." + }; + return PayloadEncryptor.EncryptToEnvelope( failResponse, connection.SharedKey, keyId ); + } + + string oldUrl = localConnection.RemoteUrl; + localConnection.RemoteUrl = inner.NewServerUrl; + _ = await dbContext.SaveChangesAsync( context.CancellationToken ); + + // Reset the cached gRPC channel so it reconnects to the new URL + clientFactory.Reset( ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Server URL updated from {OldUrl} to {NewUrl}. gRPC channel reset.", + oldUrl, inner.NewServerUrl ); + } + + NotifyServerUrlChangedResponse response = new( ) { + Acknowledged = true, + Message = "Server URL updated successfully." + }; + return PayloadEncryptor.EncryptToEnvelope( response, connection.SharedKey, keyId ); + } catch (Exception ex) when (ex is not RpcException) { + logger.LogError( ex, "Failed to process server URL change notification." ); + throw new RpcException( new Status( StatusCode.Internal, + $"Failed to update server URL: {ex.Message}" ) ); + } + } + + /// + /// Application-level heartbeat. Returns agent version and runtime information. + /// Replaces Grpc.Health.V1 for encrypted health probing. + /// + public override Task Heartbeat( + EncryptedEnvelope request, + ServerCallContext context ) { + + RegisteredConnection connection = GetConnection( context ); + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + + // Decrypt the request (validates the envelope is authentic) + HeartbeatRequest inner = PayloadEncryptor.DecryptFromEnvelope( + request, connection.SharedKey ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( + "Heartbeat from server. Agent version reported: {Version}, uptime: {Uptime}s.", + inner.AgentVersion, inner.UptimeSeconds ); + } + + HeartbeatResponse response = new( ) { + Acknowledged = true, + ServerVersion = typeof( ConnectionManagementService ).Assembly + .GetName( ).Version?.ToString( ) ?? "unknown", + }; + + return Task.FromResult( + PayloadEncryptor.EncryptToEnvelope( response, connection.SharedKey, keyId ) ); + } + + /// + /// Handles key rotation initiated by the Server. The request contains + /// a new AES-256 key RSA-encrypted with the Agent's public key. + /// The envelope itself is encrypted with the current SharedKey. + /// + public override async Task RotateSharedKey( + EncryptedEnvelope request, + ServerCallContext context ) { + + RegisteredConnection connection = GetConnection( context ); + string currentKeyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + + RotateSharedKeyRequest inner = PayloadEncryptor.DecryptFromEnvelope( + request, connection.SharedKey ); + + if (string.IsNullOrWhiteSpace( inner.NewKeyId )) { + throw new RpcException( new Status( StatusCode.InvalidArgument, "New key ID is required." ) ); + } + + if (inner.RsaEncryptedNewKey.IsEmpty) { + throw new RpcException( new Status( StatusCode.InvalidArgument, "RSA-encrypted new key is required." ) ); + } + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Received key rotation request. New KeyId={NewKeyId}.", + inner.NewKeyId ); + } + + try { + // Decrypt the new key using our RSA private key + using RSA rsa = RSA.Create( ); + rsa.ImportParameters( connection.LocalPrivateKey ); + byte[] newKey = rsa.Decrypt( inner.RsaEncryptedNewKey.ToByteArray( ), RSAEncryptionPadding.OaepSHA256 ); + + if (newKey.Length != 32) { + throw new RpcException( new Status( StatusCode.InvalidArgument, + "Decrypted key is not 32 bytes (AES-256)." ) ); + } + + // Persist: move current key to previous, install new key + using IServiceScope scope = scopeFactory.CreateScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + RegisteredConnection? localConnection = await dbContext.RegisteredConnections + .FirstOrDefaultAsync( + c => !c.IsServer && c.Status == ConnectionStatus.Connected, + context.CancellationToken ) + ?? throw new RpcException( new Status( StatusCode.Internal, + "No active connection found for key rotation." ) ); + + localConnection.PreviousSharedKey = localConnection.SharedKey; + localConnection.PreviousKeyId = localConnection.ActiveKeyId; + localConnection.SharedKey = newKey; + localConnection.ActiveKeyId = inner.NewKeyId; + + _ = await dbContext.SaveChangesAsync( context.CancellationToken ); + + // Reset the client factory so it picks up the new connection + clientFactory.Reset( ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Key rotation complete. ActiveKeyId={ActiveKeyId}, PreviousKeyId={PreviousKeyId}.", + inner.NewKeyId, currentKeyId ); + } + + // Respond with the NEW key so the server knows the agent transitioned + RotateSharedKeyResponse response = new( ) { + Success = true, + ActiveKeyId = inner.NewKeyId, + }; + return PayloadEncryptor.EncryptToEnvelope( response, newKey, inner.NewKeyId ); + } catch (Exception ex) when (ex is not RpcException) { + logger.LogError( ex, "Key rotation failed." ); + throw new RpcException( new Status( StatusCode.Internal, + $"Key rotation failed: {ex.Message}" ) ); + } + } + + private static RegisteredConnection GetConnection( ServerCallContext context ) { + return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection + ? connection + : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); + } +} diff --git a/src/Werkr.Agent/Services/OperatorOutputAdapter.cs b/src/Werkr.Agent/Services/OperatorOutputAdapter.cs new file mode 100644 index 0000000..14146f2 --- /dev/null +++ b/src/Werkr.Agent/Services/OperatorOutputAdapter.cs @@ -0,0 +1,46 @@ +using Grpc.Core; + +using Werkr.Agent.Protos; +using Werkr.Common.Protos; +using Werkr.Core.Communication; + +namespace Werkr.Agent.Services; + +/// +/// Adapts to gRPC +/// with mandatory AES-256-GCM payload encryption via . +/// Each is converted to a , +/// serialized, encrypted, and written as an independent envelope frame. +/// +internal static class OperatorOutputAdapter { + /// + /// Streams operator output to a gRPC response writer, encrypting each message + /// as an independent . + /// + /// The operator output stream. + /// The gRPC server stream writer. + /// The AES-256 shared key for encryption. Must not be null. + /// Identifier for the shared key (supports key rotation). + /// Cancellation token. + /// Thrown when is null. + public static async Task StreamToGrpc( + IAsyncEnumerable outputs, + IServerStreamWriter responseStream, + byte[] sharedKey, + string keyId, + CancellationToken cancellationToken ) { + + ArgumentNullException.ThrowIfNull( sharedKey, nameof( sharedKey ) ); + + await foreach (OperatorOutput output in outputs.WithCancellation( cancellationToken )) { + GrpcLogMsg logMsg = new( ) { + LogLevel = output.LogLevel, + Message = output.Message, + Timestamp = output.Timestamp, + }; + + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( logMsg, sharedKey, keyId ); + await responseStream.WriteAsync( envelope, cancellationToken ); + } + } +} diff --git a/src/Werkr.Agent/Services/OutputFetchService.cs b/src/Werkr.Agent/Services/OutputFetchService.cs new file mode 100644 index 0000000..7d6977b --- /dev/null +++ b/src/Werkr.Agent/Services/OutputFetchService.cs @@ -0,0 +1,100 @@ +using Grpc.Core; +using Microsoft.Extensions.Options; + +using Werkr.Common.Configuration; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Agent.Services; + +/// +/// gRPC service hosted on the Agent that allows the Server to retrieve +/// full job output on demand. The Server stores only the OutputPath +/// after a job completes; this service reads the local log file and returns +/// its content when queried. All RPCs use . +/// +/// Job output directory configuration. +/// Logger instance. +public sealed class OutputFetchService( + IOptions outputOptions, + ILogger logger +) : OutputFetch.OutputFetchBase { + + private readonly string _outputDirectory = outputOptions.Value.OutputDirectory; + + /// + /// Reads the full contents of a job's output log from the Agent's local disk. + /// + public override async Task GetJobOutput( + EncryptedEnvelope request, + ServerCallContext context ) { + + RegisteredConnection connection = GetConnection( context ); + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + + GetJobOutputRequest inner = PayloadEncryptor.DecryptFromEnvelope( + request, connection.SharedKey ); + + if (string.IsNullOrWhiteSpace( inner.JobId )) { + throw new RpcException( new Status( StatusCode.InvalidArgument, "Job ID is required." ) ); + } + + // Construct path from the output directory and job ID + // Prefer job_id-based path, fall back to output_path if provided + string filePath; + if (!string.IsNullOrWhiteSpace( inner.OutputPath )) { + // Sanitize: ensure the path stays within the output directory + string fullPath = Path.GetFullPath( Path.Combine( _outputDirectory, inner.OutputPath ) ); + string normalizedBase = Path.GetFullPath( _outputDirectory ); + if (!fullPath.StartsWith( normalizedBase, StringComparison.OrdinalIgnoreCase )) { + GetJobOutputResponse errorResponse = new( ) { + Found = false, + Error = "Output path is outside the configured output directory." + }; + return PayloadEncryptor.EncryptToEnvelope( errorResponse, connection.SharedKey, keyId ); + } + filePath = fullPath; + } else { + filePath = Path.Combine( _outputDirectory, $"{inner.JobId}.log" ); + } + + if (!File.Exists( filePath )) { + if (logger.IsEnabled( LogLevel.Warning )) { + logger.LogWarning( "Job output file not found: {FilePath} for Job {JobId}.", filePath, inner.JobId ); + } + GetJobOutputResponse notFoundResponse = new( ) { + Found = false, + Error = $"Output file not found for job {inner.JobId}." + }; + return PayloadEncryptor.EncryptToEnvelope( notFoundResponse, connection.SharedKey, keyId ); + } + + try { + string content = await File.ReadAllTextAsync( filePath, context.CancellationToken ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Served output for Job {JobId}, {Length} characters.", inner.JobId, content.Length ); + } + + GetJobOutputResponse response = new( ) { + Found = true, + Content = content, + }; + return PayloadEncryptor.EncryptToEnvelope( response, connection.SharedKey, keyId ); + } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { + logger.LogError( ex, "Failed to read output file {FilePath} for Job {JobId}.", filePath, inner.JobId ); + GetJobOutputResponse failResponse = new( ) { + Found = false, + Error = $"Failed to read output file: {ex.Message}", + }; + return PayloadEncryptor.EncryptToEnvelope( failResponse, connection.SharedKey, keyId ); + } + } + + private static RegisteredConnection GetConnection( ServerCallContext context ) { + return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection + ? connection + : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); + } +} diff --git a/src/Werkr.Agent/Services/PwshService.cs b/src/Werkr.Agent/Services/PwshService.cs new file mode 100644 index 0000000..54b4caa --- /dev/null +++ b/src/Werkr.Agent/Services/PwshService.cs @@ -0,0 +1,99 @@ +using Grpc.Core; +using Microsoft.Extensions.Options; + +using Werkr.Agent.Operators; +using Werkr.Agent.Protos; +using Werkr.Common.Configuration; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Agent.Services; + +/// +/// gRPC service implementation for PowerShell operations. +/// Authenticates via BearerTokenInterceptor, decrypts +/// requests, streams encrypted output back in envelopes. +/// +/// Creates a new . +/// The PowerShell operator. +/// Agent settings from configuration. +/// Logger for diagnostics. +public class PwshService( + PwshOperator pwshOperator, + IOptions agentSettingsOptions, + ILogger logger +) : Pwsh.PwshBase { + private readonly AgentSettings _agentSettings = agentSettingsOptions.Value; + + /// + public override async Task RunCommand( + EncryptedEnvelope request, + IServerStreamWriter responseStream, + ServerCallContext context ) { + + ValidatePowerShellEnabled( ); + RegisteredConnection connection = GetConnection( context ); + + ShellRequest shellRequest = PayloadEncryptor.DecryptFromEnvelope( request, connection.SharedKey ); + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Executing PowerShell command for connection {ConnectionId}.", connection.Id.ToString( ) ); + } + + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + OperatorExecution execution = pwshOperator.RunCommand( shellRequest.Command, context.CancellationToken ); + await OperatorOutputAdapter.StreamToGrpc( execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); + } + + /// + public override async Task RunScript( + EncryptedEnvelope request, + IServerStreamWriter responseStream, + ServerCallContext context ) { + + ValidatePowerShellEnabled( ); + RegisteredConnection connection = GetConnection( context ); + + ShellRequest shellRequest = PayloadEncryptor.DecryptFromEnvelope( request, connection.SharedKey ); + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Executing PowerShell script for connection {ConnectionId}.", connection.Id.ToString( ) ); + } + + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + OperatorExecution execution = pwshOperator.RunScript( shellRequest.Command, context.CancellationToken ); + await OperatorOutputAdapter.StreamToGrpc( execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); + } + + /// + public override async Task RunScriptWithArgs( + EncryptedEnvelope request, + IServerStreamWriter responseStream, + ServerCallContext context ) { + + ValidatePowerShellEnabled( ); + RegisteredConnection connection = GetConnection( context ); + + ScriptRequest scriptRequest = PayloadEncryptor.DecryptFromEnvelope( request, connection.SharedKey ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Executing PowerShell script with args for connection {ConnectionId}.", connection.Id.ToString( ) ); + } + + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + OperatorExecution execution = pwshOperator.RunScriptWithArgs( scriptRequest.Script, [.. scriptRequest.Args], context.CancellationToken ); + await OperatorOutputAdapter.StreamToGrpc( execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); + } + + private void ValidatePowerShellEnabled( ) { + if (!_agentSettings.EnablePowerShell) { + throw new RpcException( new Status( StatusCode.Unimplemented, "PowerShell is disabled on this agent." ) ); + } + } + + private static RegisteredConnection GetConnection( ServerCallContext context ) { + return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection + ? connection + : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); + } +} diff --git a/src/Werkr.Agent/Services/ScheduleInvalidationService.cs b/src/Werkr.Agent/Services/ScheduleInvalidationService.cs new file mode 100644 index 0000000..f0922d0 --- /dev/null +++ b/src/Werkr.Agent/Services/ScheduleInvalidationService.cs @@ -0,0 +1,76 @@ +using System.Threading.Channels; + +using Grpc.Core; + +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Agent.Services; + +/// +/// gRPC service hosted on the Agent that receives schedule invalidation +/// notifications from the Server. When a schedule is updated or deleted, +/// the Server calls this service so the Agent can re-sync immediately +/// rather than waiting for its next periodic sync interval. +/// All RPCs use . +/// +/// +/// Channel used to signal the ScheduleEvaluatorService that a +/// schedule needs re-syncing. Writers are this service; the reader is +/// the evaluator's background loop. +/// +/// Logger instance. +public sealed class ScheduleInvalidationService( + Channel invalidationChannel, + ILogger logger +) : ScheduleInvalidation.ScheduleInvalidationBase { + + /// + /// Handles a schedule invalidation push from the Server. + /// Decrypts the envelope to , then + /// writes the schedule ID to the shared invalidation channel so the + /// ScheduleEvaluatorService can trigger a re-sync. + /// + public override async Task InvalidateSchedule( + EncryptedEnvelope request, + ServerCallContext context ) { + + RegisteredConnection connection = GetConnection( context ); + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + + InvalidateScheduleRequest inner = PayloadEncryptor.DecryptFromEnvelope( + request, connection.SharedKey ); + + if (string.IsNullOrWhiteSpace( inner.ScheduleId )) { + throw new RpcException( new Status( StatusCode.InvalidArgument, "Schedule ID is required." ) ); + } + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Received schedule invalidation for ScheduleId={ScheduleId}.", + inner.ScheduleId ); + } + + // Write to the unbounded channel — never blocks + bool written = invalidationChannel.Writer.TryWrite( inner.ScheduleId ); + if (!written) { + logger.LogWarning( + "Invalidation channel rejected write for ScheduleId={ScheduleId}. Channel may be completed.", + inner.ScheduleId ); + } + + InvalidateScheduleResponse response = new( ) { + Acknowledged = written, + }; + + return await Task.FromResult( + PayloadEncryptor.EncryptToEnvelope( response, connection.SharedKey, keyId ) ); + } + + private static RegisteredConnection GetConnection( ServerCallContext context ) { + return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection + ? connection + : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); + } +} diff --git a/src/Werkr.Agent/Services/SystemShellService.cs b/src/Werkr.Agent/Services/SystemShellService.cs new file mode 100644 index 0000000..47d4c87 --- /dev/null +++ b/src/Werkr.Agent/Services/SystemShellService.cs @@ -0,0 +1,99 @@ +using Grpc.Core; +using Microsoft.Extensions.Options; + +using Werkr.Agent.Operators; +using Werkr.Agent.Protos; +using Werkr.Common.Configuration; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Agent.Services; + +/// +/// gRPC service implementation for system shell operations (cmd.exe / bash). +/// Authenticates via BearerTokenInterceptor, decrypts +/// requests, streams encrypted output back in envelopes. +/// +/// Creates a new . +/// The system shell operator. +/// Agent settings from configuration. +/// Logger for diagnostics. +public class SystemShellService( + SystemShellOperator shellOperator, + IOptions agentSettingsOptions, + ILogger logger +) : SystemShell.SystemShellBase { + private readonly AgentSettings _agentSettings = agentSettingsOptions.Value; + + /// + public override async Task RunCommand( + EncryptedEnvelope request, + IServerStreamWriter responseStream, + ServerCallContext context ) { + + ValidateSystemShellEnabled( ); + RegisteredConnection connection = GetConnection( context ); + + ShellRequest shellRequest = PayloadEncryptor.DecryptFromEnvelope( request, connection.SharedKey ); + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Executing system shell command for connection {ConnectionId}.", connection.Id.ToString( ) ); + } + + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + OperatorExecution execution = shellOperator.RunCommand( shellRequest.Command, context.CancellationToken ); + await OperatorOutputAdapter.StreamToGrpc( execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); + } + + /// + public override async Task RunScript( + EncryptedEnvelope request, + IServerStreamWriter responseStream, + ServerCallContext context ) { + + ValidateSystemShellEnabled( ); + RegisteredConnection connection = GetConnection( context ); + + ShellRequest shellRequest = PayloadEncryptor.DecryptFromEnvelope( request, connection.SharedKey ); + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Executing system shell script for connection {ConnectionId}.", connection.Id.ToString( ) ); + } + + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + OperatorExecution execution = shellOperator.RunScript( shellRequest.Command, context.CancellationToken ); + await OperatorOutputAdapter.StreamToGrpc( execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); + } + + /// + public override async Task RunScriptWithArgs( + EncryptedEnvelope request, + IServerStreamWriter responseStream, + ServerCallContext context ) { + + ValidateSystemShellEnabled( ); + RegisteredConnection connection = GetConnection( context ); + + ScriptRequest scriptRequest = PayloadEncryptor.DecryptFromEnvelope( request, connection.SharedKey ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Executing system shell script with args for connection {ConnectionId}.", connection.Id.ToString( ) ); + } + + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + OperatorExecution execution = shellOperator.RunScriptWithArgs( scriptRequest.Script, [.. scriptRequest.Args], context.CancellationToken ); + await OperatorOutputAdapter.StreamToGrpc( execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); + } + + private void ValidateSystemShellEnabled( ) { + if (!_agentSettings.EnableSystemShell) { + throw new RpcException( new Status( StatusCode.Unimplemented, "System shell is disabled on this agent." ) ); + } + } + + private static RegisteredConnection GetConnection( ServerCallContext context ) { + return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection + ? connection + : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); + } +} diff --git a/src/Werkr.Agent/Werkr.Agent.csproj b/src/Werkr.Agent/Werkr.Agent.csproj new file mode 100644 index 0000000..b7adbc5 --- /dev/null +++ b/src/Werkr.Agent/Werkr.Agent.csproj @@ -0,0 +1,45 @@ + + + + Werkr.Agent + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Werkr.Agent/appsettings.Development.json b/src/Werkr.Agent/appsettings.Development.json new file mode 100644 index 0000000..5b487d1 --- /dev/null +++ b/src/Werkr.Agent/appsettings.Development.json @@ -0,0 +1,13 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.AspNetCore": "Warning", + "Grpc": "Debug", + "System": "Information" + } + } + } +} diff --git a/src/Werkr.Agent/appsettings.json b/src/Werkr.Agent/appsettings.json new file mode 100644 index 0000000..8d3d91f --- /dev/null +++ b/src/Werkr.Agent/appsettings.json @@ -0,0 +1,41 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning", + "Grpc": "Information", + "System": "Warning" + } + }, + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "File", + "Args": { + "path": "logs/agent-log-.txt", + "rollingInterval": "Day" + } + }, + { "Name": "OpenTelemetry" } + ], + "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ] + }, + "Agent": { + "Name": "Default Agent", + "GrpcPort": 5100, + "EnablePowerShell": true, + "EnableSystemShell": true, + "PowerShell": { + "BufferWidth": 150 + } + }, + "ConnectionStrings": { + "AgentDb": "Data Source=werkr-agent.db" + }, + "ActionOperator": { + "DefaultTimeout": "01:00:00" + }, + "AllowedHosts": "*" +} diff --git a/src/Werkr.Agent/packages.lock.json b/src/Werkr.Agent/packages.lock.json new file mode 100644 index 0000000..fcf4fbc --- /dev/null +++ b/src/Werkr.Agent/packages.lock.json @@ -0,0 +1,1159 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Grpc.AspNetCore": { + "type": "Direct", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==", + "dependencies": { + "Google.Protobuf": "3.31.1", + "Grpc.AspNetCore.Server.ClientFactory": "2.76.0", + "Grpc.Tools": "2.76.0" + } + }, + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.78.0, )", + "resolved": "2.78.0", + "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" + }, + "Microsoft.PowerShell.SDK": { + "type": "Direct", + "requested": "[7.5.4, )", + "resolved": "7.5.4", + "contentHash": "VjRoL4Eja88vOpEflx17ijURIZ3Q5780PTAD8XYhXmlMca6uUghh3qwhpWOQJF8OpYOLUiA6fRPRvoayX2BSXA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "Microsoft.Management.Infrastructure.CimCmdlets": "7.5.4", + "Microsoft.PowerShell.Commands.Diagnostics": "7.5.4", + "Microsoft.PowerShell.Commands.Management": "7.5.4", + "Microsoft.PowerShell.Commands.Utility": "7.5.4", + "Microsoft.PowerShell.ConsoleHost": "7.5.4", + "Microsoft.PowerShell.Security": "7.5.4", + "Microsoft.WSMan.Management": "7.5.4", + "Microsoft.Win32.Registry.AccessControl": "9.0.10", + "Microsoft.Win32.SystemEvents": "9.0.10", + "Microsoft.Windows.Compatibility": "9.0.10", + "System.CodeDom": "9.0.10", + "System.ComponentModel.Composition": "9.0.10", + "System.ComponentModel.Composition.Registration": "9.0.10", + "System.Configuration.ConfigurationManager": "9.0.10", + "System.Data.Odbc": "9.0.10", + "System.Data.OleDb": "9.0.10", + "System.Data.SqlClient": "4.9.0", + "System.Diagnostics.PerformanceCounter": "9.0.10", + "System.DirectoryServices": "9.0.10", + "System.DirectoryServices.AccountManagement": "9.0.10", + "System.DirectoryServices.Protocols": "9.0.10", + "System.Drawing.Common": "9.0.10", + "System.IO.Packaging": "9.0.10", + "System.IO.Ports": "9.0.10", + "System.Management": "9.0.10", + "System.Management.Automation": "7.5.4", + "System.Net.Http.WinHttpHandler": "9.0.10", + "System.Private.ServiceModel": "4.10.3", + "System.Reflection.Context": "9.0.10", + "System.Runtime.Caching": "9.0.10", + "System.Security.Cryptography.Pkcs": "9.0.10", + "System.Security.Cryptography.ProtectedData": "9.0.10", + "System.Security.Permissions": "9.0.10", + "System.ServiceModel.Duplex": "4.10.3", + "System.ServiceModel.Http": "4.10.3", + "System.ServiceModel.NetTcp": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3", + "System.ServiceModel.Security": "4.10.3", + "System.ServiceProcess.ServiceController": "9.0.10", + "System.Speech": "9.0.10", + "System.Web.Services.Description": "8.0.0", + "System.Windows.Extensions": "9.0.10", + "runtime.android-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-x86.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.native.System.IO.Ports": "9.0.10", + "runtime.osx-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.osx-x64.runtime.native.System.IO.Ports": "9.0.10" + } + }, + "Serilog.AspNetCore": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "Direct", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Sinks.OpenTelemetry": { + "type": "Direct", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", + "dependencies": { + "Google.Protobuf": "3.30.1", + "Grpc.Net.Client": "2.70.0", + "Serilog": "4.2.0" + } + }, + "Grpc.AspNetCore.Server": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0" + } + }, + "Grpc.AspNetCore.Server.ClientFactory": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==", + "dependencies": { + "Grpc.AspNetCore.Server": "2.76.0", + "Grpc.Net.ClientFactory": "2.76.0" + } + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==", + "dependencies": { + "Grpc.Core.Api": "2.76.0" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Json.More.Net": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "izscdjjk8EAHDBCjyz7V7n77SzkrSjh/hUGV6cyR6PlVdjYDh5ohc8yqvwSqJ9+6Uof8W6B24dIHlDKD+I1F8A==" + }, + "JsonPointer.Net": { + "type": "Transitive", + "resolved": "5.0.2", + "contentHash": "H/OtixKadr+ja1j7Fru3WG56V9zP0AKT1Bd0O7RWN/zH1bl8ZIwW9aCa4+xvzuVvt4SPmrvBu3G6NpAkNOwNAA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Json.More.Net": "2.0.1.2" + } + }, + "JsonSchema.Net": { + "type": "Transitive", + "resolved": "7.2.3", + "contentHash": "O3KclMcPVFYTZsTeZBpwtKd/lYrNc3AFR+xi9j3Q4CfhDufOUx25TMMWJOcFRrqVklvKQ4Kl+0UhlNX1iDGoRw==", + "dependencies": { + "JsonPointer.Net": "5.0.0" + } + }, + "Markdig.Signed": { + "type": "Transitive", + "resolved": "0.38.0", + "contentHash": "zfi6kNm5QJnsCGm5a0hMG2qw8juYbOfsS4c1OuTcqkbYQUCdkam6d6Nt7nPIrbV4D+U7sHChidSQlg+ViiMPuw==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.22.0", + "contentHash": "3AOM9bZtku7RQwHyMEY3tQMrHIgjcfRDa6YQpd/QG2LDGvMydSlL9Di+8LLMt7J2RDdfJ7/2jdYv6yHcMJAnNw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.11.0", + "contentHash": "djf8ujmqYImFgB04UGtcsEhHrzVqzHowS+EEl/Yunc5LdrYrZhGBWUTXoCF0NzYXJxtfuD+UVQarWpvrNc94Qg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.4" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.11.0", + "contentHash": "6XYi2EusI8JT4y2l/F3VVVS+ISoIX9nqHsZRaG6W5aFeJ5BEuBosHfT/ABb73FN0RZ1Z3cj2j7cL28SToJPXOw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.4", + "Microsoft.CodeAnalysis.Common": "[4.11.0]" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==" + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==" + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==" + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", + "dependencies": { + "Microsoft.Extensions.Telemetry": "10.3.0" + } + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.ServiceDiscovery.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==" + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.Management.Infrastructure": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "cGZi0q5IujCTVYKo9h22Pw+UwfZDV82HXO8HTxMG2HqntPlT3Ls8jY6punLp4YzCypJNpfCAu2kae3TIyuAiJw==", + "dependencies": { + "Microsoft.Management.Infrastructure.Runtime.Unix": "3.0.0", + "Microsoft.Management.Infrastructure.Runtime.Win": "3.0.0" + } + }, + "Microsoft.Management.Infrastructure.CimCmdlets": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "p2nh2bDZGeAsOLd/QwRrZGahPV1Jy1Z0LNA/ZSqpyN8Cp31qh1UOfpmq4rss5P5deuygAN6DTLn96LY5oEDQpg==", + "dependencies": { + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.Management.Infrastructure.Runtime.Unix": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "QZE3uEDvZ0m7LabQvcmNOYHp7v1QPBVMpB/ild0WEE8zqUVAP5y9rRI5we37ImI1bQmW5pZ+3HNC70POPm0jBQ==" + }, + "Microsoft.Management.Infrastructure.Runtime.Win": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "uwMyWN33+iQ8Wm/n1yoPXgFoiYNd0HzJyoqSVhaQZyJfaQrJR3udgcIHjqa1qbc3lS6kvfuUMN4TrF4U4refCQ==" + }, + "Microsoft.PowerShell.Commands.Diagnostics": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "sRBHmXm2Ivy6pyAI2OX5PJ1DXbmmA1/OusFEXwdWWEjjiZ0prul3POc3GJoiMSn6WF5dJ6xw53MKZrkvu4uCgA==", + "dependencies": { + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.PowerShell.Commands.Management": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "OhkLYDIf2xeexTWi+3yBRIrGMCpBBDPGAzKAp0wLCj3IE1D2H1Uj4XEE67y69eLFx7jxVwy2Er9hoTt5joECig==", + "dependencies": { + "Microsoft.PowerShell.Security": "7.5.4", + "System.ServiceProcess.ServiceController": "9.0.10" + } + }, + "Microsoft.PowerShell.Commands.Utility": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "lvVh2zHEC2EnBImCRpu9b+5qqngE5o76gVI1NzIfReBXNtsym51XmX/kCrN0INm98CN3GoxTBa7WTcTJC1H3dw==", + "dependencies": { + "Json.More.Net": "2.0.2", + "JsonPointer.Net": "5.0.2", + "JsonSchema.Net": "7.2.3", + "Markdig.Signed": "0.38.0", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "4.11.0", + "Microsoft.PowerShell.MarkdownRender": "7.2.1", + "Microsoft.Win32.SystemEvents": "9.0.10", + "System.Drawing.Common": "9.0.10", + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.PowerShell.ConsoleHost": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "0U3DkO631KXj5m4jfsKQrUT795ZvZZAzvjNTNdhO4YNukwSSSzJUTszVVE2NXwbkQZHuAoUjPTwicINZJ87OoQ==", + "dependencies": { + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.PowerShell.CoreCLR.Eventing": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "1xyl5hcWKs5IDFO1ZWXSoVLPN78CJpo6GykVg3F/kNHkldixODi6yz1bbVmyEAMC64AvA3ZKSs/AZaGNoKTI+w==" + }, + "Microsoft.PowerShell.MarkdownRender": { + "type": "Transitive", + "resolved": "7.2.1", + "contentHash": "o5oUwL23R/KnjQPD2Oi49WAG5j4O4VLo1fPRSyM/aq0HuTrY2RnF4B3MCGk13BfcmK51p9kPlHZ1+8a/ZjO4Jg==", + "dependencies": { + "Markdig.Signed": "0.31.0" + } + }, + "Microsoft.PowerShell.Native": { + "type": "Transitive", + "resolved": "7.4.0", + "contentHash": "FlaJ3JBWhqFToYT0ycMb/Xxzoof7oTQbNyI4UikgubC7AMWt5ptBNKjIAMPvOcvEHr+ohaO9GvRWp3tiyS3sKw==" + }, + "Microsoft.PowerShell.Security": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "k/TMcn7ETkq91qhzncGbHthOEzZjGzcq6U6E4exyJRsRqe2MqaRXGrPifiCXDJ6I/dSQOclSpqFSqE/SWVUFdQ==", + "dependencies": { + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.Security.Extensions": { + "type": "Transitive", + "resolved": "1.4.0", + "contentHash": "MnHXttc0jHbRrGdTJ+yJBbGDoa4OXhtnKXHQw70foMyAooFtPScZX/dN+Nib47nuglc9Gt29Gfb5Zl+1lAuTeA==" + }, + "Microsoft.Win32.Registry.AccessControl": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "ZYHfH0wgTa4usqMMetFYezSjfkQaMat83b/Ykz1q4qSx1h/OiXFb8ZSsn3ZKttHcxe1bn5m/+Zjz9deVT45L8w==" + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "P1CEtsxar/RhfoH3r1vc9ra28LLVYphpcFBxyRIEMM/jP3qh4j9TU4sWH2RUhMZX+GbFxZ+zz1oSP2n9MwjshA==" + }, + "Microsoft.Windows.Compatibility": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "nCkAfadYeJNfJE/RoKGKFlIHVzovN6/DhLm4ebaCBiLWnP6R/fe22n3BWWqlkT2ignu+GTBrkNLs64e8yCCmGw==", + "dependencies": { + "Microsoft.Win32.Registry.AccessControl": "9.0.10", + "Microsoft.Win32.SystemEvents": "9.0.10", + "System.CodeDom": "9.0.10", + "System.ComponentModel.Composition": "9.0.10", + "System.ComponentModel.Composition.Registration": "9.0.10", + "System.Configuration.ConfigurationManager": "9.0.10", + "System.Data.Odbc": "9.0.10", + "System.Data.OleDb": "9.0.10", + "System.Data.SqlClient": "4.9.0", + "System.Diagnostics.PerformanceCounter": "9.0.10", + "System.DirectoryServices": "9.0.10", + "System.DirectoryServices.AccountManagement": "9.0.10", + "System.DirectoryServices.Protocols": "9.0.10", + "System.Drawing.Common": "9.0.10", + "System.IO.Packaging": "9.0.10", + "System.IO.Ports": "9.0.10", + "System.Management": "9.0.10", + "System.Reflection.Context": "9.0.10", + "System.Runtime.Caching": "9.0.10", + "System.Security.Cryptography.Pkcs": "9.0.10", + "System.Security.Cryptography.ProtectedData": "9.0.10", + "System.Security.Permissions": "9.0.10", + "System.ServiceModel.Duplex": "4.10.3", + "System.ServiceModel.Http": "4.10.3", + "System.ServiceModel.NetTcp": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3", + "System.ServiceModel.Security": "4.10.3", + "System.ServiceModel.Syndication": "9.0.10", + "System.ServiceProcess.ServiceController": "9.0.10", + "System.Speech": "9.0.10", + "System.Web.Services.Description": "4.10.3" + } + }, + "Microsoft.WSMan.Management": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "VaLrRXOuIlflS1zonDAbuKdADLojCeSdDy4d4vILa1l2SO3Yaheh3gGP3g4emPCeVDN75ZokigH8Ehe0OeNO1A==", + "dependencies": { + "Microsoft.WSMan.Runtime": "7.5.4", + "System.Management.Automation": "7.5.4", + "System.ServiceProcess.ServiceController": "9.0.10" + } + }, + "Microsoft.WSMan.Runtime": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "Kw5tys1LdJRl/Sn3qT5Os0VJev1o5TGPPfrd7SfxUFiHLcYeiO0IQGFpumZ9SXr4FxPot1125iu3l2a2VEEBZw==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==" + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", + "dependencies": { + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", + "dependencies": { + "OpenTelemetry.Api": "1.15.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2" + } + }, + "runtime.android-arm.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "KUeHD0wRFCTS9QHantD5Cv/RzDzVY/mQP1Z/eKLtlX5A5SZvsqeomAoayPdh/QmgSzquoHeIDMAMp8VVU+Xzag==" + }, + "runtime.android-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "b+z8JoBrZ5TMXiXeh0s+s9/uIVx6PmulEuMaN81JLM68aAb4DWHi7t5CL+8bWJhsFhd8VAYoZ9pi5miNTFPeuA==" + }, + "runtime.android-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "K7u+/G2gPoRLNc974p1Tnp44VRLlpQWZrKEQofBTpyJZPgd46ayvXayqT4jyGodG4O6Q6+yY2pYUYlqv1K2l6w==" + }, + "runtime.android-x86.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "TGT4P40ockzrZf/K46A3VAl2dC2PWAS6WhqqtZJbH5G7XgBMX/FoaoY6DtFtz6u7RVl4zhjdG3QWXEv/u/1Hlg==" + }, + "runtime.linux-arm.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "Y2EEaUtO1JolypkFcqgsDxjmOleHa7d9OxBY4Osw5vIdQpOfP0Qj30czQfkN7cZTQH8NxsSr5WawVbk5yFabFg==" + }, + "runtime.linux-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "nIFQGoz22wdgtmS4Ce+weqGUBh1kpO4XbNEgCU01+7P/+yZAb+gbRSeJUyUmCPhyW0S8FhX1xgJDH/SiJgP05Q==" + }, + "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "jV3esGC4j69yPlRzj50EbJq4syweBm4rOWKYJ3nWCMbVzTW1YQ2o4QhiVjCDOEKEf6q5eVGEaa6fyVXQ/K95Hw==" + }, + "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "UfoiSWuf75mYJPknRXSezDoFYaCp5dWoUjASjg6gQSa7FD2G59Mee6vMEzHFS+x8N+H8oNnL9TCIZUD4/8e/2Q==" + }, + "runtime.linux-musl-arm.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "CWwqjMVVtYiW4I9wk9YuUaSxxkPZKZ/BKj5ppAsIZv4X9u/dyh8+Qbj3Fly61uUXpGxXU4QFQhYuPi5pJTAOBA==" + }, + "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "NxMtoYPV8lreQggsXWsXXRF/djycKieThc4O2kxGB6EgjsiRDuNdnbODV0lWGV6v4mn08uQvuOhmBP5ZYpVdkA==" + }, + "runtime.linux-musl-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "ZNQ/D4lUGxTbfGAZRWP4vp7tCLqvInit03YXAiFXDWh/DnMEosBjrwcu8vbWgSsF01DUbyZQai8lwAStIZWo8w==" + }, + "runtime.linux-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "mOTksL8qwN9EiWz71Dzhc98iQhKmtHlWH5GbhiCJ+ES2ei1HLr2bXSNMrXRd5s0Wfzg6xeQmT5M9umcgtv8Bzg==" + }, + "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "aKoLfCdLoQGhPh2VYBn6sn1kDuwgtXKJ4D2Ql/2WLCGCuXestpxLBS0JhSVBFSp1HrFdazj4aSwpYurtes+1Gg==" + }, + "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "B+boSbUptH2fiKiUXBIu5hlQ3oH+nAdPY9TNmpU10nuoFW/DNz21fCXY3UOIz9oRXp5Ao2b7RlurgpBl0AgoOQ==" + }, + "runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "A8v6PGmk+UGbfWo5Ixup0lPM4swuSwOiayJExZwKIOjTlFFQIsu3QnDXECosBEyrWSPryxBVrdqtJyhK3BaupQ==", + "dependencies": { + "runtime.win-arm64.runtime.native.System.Data.SqlClient.sni": "4.4.0", + "runtime.win-x64.runtime.native.System.Data.SqlClient.sni": "4.4.0", + "runtime.win-x86.runtime.native.System.Data.SqlClient.sni": "4.4.0" + } + }, + "runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "7AzXN+J8PkTVctfymH+tEaAmj0wNKFPyACqp5cYff0DrHxnsQhv7xtRWxJRrQ0azOAFGR1mhWN4aM1QkbQQ0Rw==", + "dependencies": { + "runtime.android-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-x86.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.osx-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.osx-x64.runtime.native.System.IO.Ports": "9.0.10" + } + }, + "runtime.osx-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "m+gRRrmCTwP30YiVnFeZg/zRWgzVcOlN28cIPMkK11C9UU60waLknTnRLlQUagIkWaCDifKJCB6wtEeca5QiMA==" + }, + "runtime.osx-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "89HgF1Oplzjomn0BLeqJxEC17d//zmbs7CMKT12ZvjvFMvpMFO8uQUZ9xRIh91rM0ByfbhSobe2IRezjpeDNlg==" + }, + "runtime.win-arm64.runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "LbrynESTp3bm5O/+jGL8v0Qg5SJlTV08lpIpFesXjF6uGNMWqFnUQbYBJwZTeua6E/Y7FIM1C54Ey1btLWupdg==" + }, + "runtime.win-x64.runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "38ugOfkYJqJoX9g6EYRlZB5U2ZJH51UP8ptxZgdpS07FgOEToV+lS11ouNK2PM12Pr6X/PpT5jK82G3DwH/SxQ==" + }, + "runtime.win-x86.runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "YhEdSQUsTx+C8m8Bw7ar5/VesXvCFMItyZF7G1AUY+OM0VPZUOeAVpJ4Wl6fydBGUYZxojTDR3I6Bj/+BPkJNA==" + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "00dAIR9Zx+F+AaipjaQmudX3VVpzYvT0bKVD3WcJq6om6pKNrldnp5bSR0VV6IlwDBa1HObGD+sTFaT/I9bBng==" + }, + "System.ComponentModel.Composition": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "tLJKLlc3VsjTLZ4aAwKicKfLKTAzTSSod+T6TWQSjmmA2JMgVvsU5QA2Ka2+Gq2M8poLaxY2dAipFsJen+ZI/g==" + }, + "System.ComponentModel.Composition.Registration": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "H+iSxY02ucdevQa+4jc5disuSgiLom2gUrdATFmVFWc/1De5HBtssVdcar2mxDbtT5IBKiMvwXVHrnl5jmaQtw==", + "dependencies": { + "System.ComponentModel.Composition": "9.0.10", + "System.Reflection.Context": "9.0.10" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "5CBhl5dWmckKEtvk8F6GXtmHxNBoqAC8xILxIntNm7AzHiXQ09CXSLhncIJ/cQWaiNYzLjHZCgtMfx9tkCKHdA==", + "dependencies": { + "System.Security.Cryptography.ProtectedData": "9.0.10" + } + }, + "System.Data.Odbc": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "1GjZfLbeSdfHhKUFhk4oU6f3PSF2DOFILTPLHDuC8Pj7UWvwnl8a+H7LDtwEqIJuZ0O2n0rMjydm+Fn67u0G2w==" + }, + "System.Data.OleDb": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "LwiN01NosLlqowmrD1ej1qM1O3GVZeQZzbWrTwYLyeQUGyTVt8yVsTgsRnIJmKny1ENdVcQ9WhKUjzBnh37fsQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "9.0.10", + "System.Diagnostics.PerformanceCounter": "9.0.10" + } + }, + "System.Data.SqlClient": { + "type": "Transitive", + "resolved": "4.9.0", + "contentHash": "j4KJO+vC62NyUtNHz854njEqXbT8OmAa5jb1nrGfYWBOcggyYUQE0w/snXeaCjdvkSKWuUD+hfvlbN8pTrJTXg==", + "dependencies": { + "runtime.native.System.Data.SqlClient.sni": "4.4.0" + } + }, + "System.Diagnostics.PerformanceCounter": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "35eXaOLXv8ATGDVr946gK0sNEEOwuFzhjFjTQftWh0swhLiyIjAD1pu17tu/SVENpKPZwqJ2e7IIcLpIs0GEzQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "9.0.10" + } + }, + "System.DirectoryServices": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "dlSYvBLD/XlW2y7hJA+INfcRRtkouFSEcYSVoYmxwfurVdYJ088+PUYf8kgszAp3cThpMPAPVhNHl1lMYrv9kw==" + }, + "System.DirectoryServices.AccountManagement": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "5sNlMrUPhEH8gmosdAz2ZuKA4S4fBdnkpgw5C9IIgyZzy8xg8wPj9aX5oBhoep48tqDVz0++DBWJxJsi4UjT+A==", + "dependencies": { + "System.Configuration.ConfigurationManager": "9.0.10", + "System.DirectoryServices": "9.0.10", + "System.DirectoryServices.Protocols": "9.0.10" + } + }, + "System.DirectoryServices.Protocols": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "nyJa6GTsPxNYt08Ssl9xHXLyDGozVkmsWgmAegUw9+4TBvS8BO1oV69XlkbyF+oJ6qR4+VPy7lgDWUMapvQfUg==" + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "FDakPhIcxHnhslLiz4ZQ+ALpHRpCU3zOep9Mcq+4hL23XwQrzmgJNYvf1tH4kJ/V36wO/ZhRr8nOfiz26P3wKg==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "9.0.10" + } + }, + "System.IO.Packaging": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "dKlnLbyOKFCLa5rda8yUU6M0HhVLMkB7rf9lEWnXVtHdNlq9A/fJmt7s/OhwbYaUfOO8rxshpQLyPn0Pv1a2lQ==" + }, + "System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "jMvwu+NOk/+vlOzTp9vpxIeGq+yRA+3EbkmpLMs37AAy9cI8YlY/ntTHL00w26Tvu6cIkx0/TdjmeHm0l99Nqw==", + "dependencies": { + "runtime.native.System.IO.Ports": "9.0.10" + } + }, + "System.Management": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "kJY2C6MjKSqfRkEnc8gn4Jth81Anrgxxpu0MffjEadfpp0Ll/gdGpYnDhRWZd+iFttkfZC0uCjFmCrZARRqq4w==", + "dependencies": { + "System.CodeDom": "9.0.10" + } + }, + "System.Management.Automation": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "kHvz4Gc2sQ670KNU+CMsCmoxSM+hO+qW9ujyf3MbBuDImuKeHL8oo2gq4kZpuncO/MSeOTstx3pW8YE6jqIZYA==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.22.0", + "Microsoft.Management.Infrastructure": "3.0.0", + "Microsoft.PowerShell.CoreCLR.Eventing": "7.5.4", + "Microsoft.PowerShell.Native": "7.4.0", + "Microsoft.Security.Extensions": "1.4.0", + "Microsoft.Win32.Registry.AccessControl": "9.0.10", + "Newtonsoft.Json": "13.0.4", + "System.CodeDom": "9.0.10", + "System.Configuration.ConfigurationManager": "9.0.10", + "System.DirectoryServices": "9.0.10", + "System.Management": "9.0.10", + "System.Security.Cryptography.Pkcs": "9.0.10", + "System.Security.Cryptography.ProtectedData": "9.0.10", + "System.Security.Permissions": "9.0.10", + "System.Windows.Extensions": "9.0.10" + } + }, + "System.Net.Http.WinHttpHandler": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "D7CvYoTJPp/gDP3CMKxyUXUpfs8pFi4mQs+USHlT3Bqq6b83Lqe7gOn/dVPVZ78d2/cimxcqnpB9N2f1cDllWg==" + }, + "System.Private.ServiceModel": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "BcUV7OERlLqGxDXZuIyIMMmk1PbqBblLRbAoigmzIUx/M8A+8epvyPyXRpbgoucKH7QmfYdQIev04Phx2Co08A==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0" + } + }, + "System.Reflection.Context": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "Dv7cY++FuibtTyQfWR7ZVMjdtblYkRH6po+UiyBsUwNri2T+afSqwpZq4F2zsVGxtsNsZpXbrJCDs4PxvwxMrQ==" + }, + "System.Runtime.Caching": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "WFKbtzR8mfIZWeQlYGtyjMcse3DoNR0zLsNAev2dDYM8pY945EzzLPO84qnVa+BIEDF1woD8+TtboWSh65U2DQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "9.0.10" + } + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "Pg7QZz80fOJZrtJnAdEAIpeor8q7F1ofwXGYgLNr4dR8Mqf2l7lfeTaodQkRetrj+ClQwVVYoyi6g2eOsmstFw==" + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "uqzXSkn2nx9nplIdayurMtbLcQQdOGd7TmIQ+X5P65+QWT2S+1aUZfJuH2f+Blr/4W6wxMkiX9aKzLk7lfMZFQ==", + "dependencies": { + "System.Windows.Extensions": "9.0.10" + } + }, + "System.ServiceModel.Duplex": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "IZ8ZahvTenWML7/jGUXSCm6jHlxpMbcb+Hy+h5p1WP9YVtb+Er7FHRRGizqQMINEdK6HhWpD6rzr5PdxNyusdg==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3" + } + }, + "System.ServiceModel.Http": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "hodkn0rPTYmoZ9EIPwcleUrOi1gZBPvU0uFvzmJbyxl1lIpVM5GxTrs/pCETStjOXCiXhBDoZQYajquOEfeW/w==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3" + } + }, + "System.ServiceModel.NetTcp": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "tP7GN7ehqSIQEz7yOJEtY8ziTpfavf2IQMPKa7r9KGQ75+uEW6/wSlWez7oKQwGYuAHbcGhpJvdG6WoVMKYgkw==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3" + } + }, + "System.ServiceModel.Primitives": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "aNcdry95wIP1J+/HcLQM/f/AA73LnBQDNc2uCoZ+c1//KpVRp8nMZv5ApMwK+eDNVdCK8G0NLInF+xG3mfQL+g==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3" + } + }, + "System.ServiceModel.Security": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "vqelKb7DvP2inb6LDJ5Igi8wpOYdtLXn5luDW5qEaqkV2sYO1pKlVYBpr6g6m5SevzbdZlVNu67dQiD/H6EdGQ==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3" + } + }, + "System.ServiceModel.Syndication": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "jWOXgKi51ULlPDi+YIWsZglIYUYC1DixAs2j6xdy8fzhuxvXO82yUEXv4wFziqzoG1FmTAV/uv5psxb+3MqB7w==" + }, + "System.ServiceProcess.ServiceController": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "dmH+qHQ5wMjvEI0M2s6J+vmaU9L9ID2D9DWMFa7FiTfINfo3e3zeL4ljX7Dg5gCnFIULPFip2ej2iIAC3X6MFw==" + }, + "System.Speech": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "rtbgAR0AD2yij7tqh/TJFAvsr1KN+Q8hb8JUcAN7uLh5EAkQ8Z4o7bFTQpcZDPec3/KsBFPHZNQS0nTLHEdmwQ==" + }, + "System.Web.Services.Description": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "6pwntR5vqLOzUPU9GcLVNEASAVf0GFeXoRF4p/SWIiU3073ZbWJ6dJM5cpXgylcbJDjlwPqNx9f5Y4Od0cNfDA==" + }, + "System.Windows.Extensions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "6I+OzjcTx2gtZotjDQXEhWdkfPVxRvT9r9nFWsgt9Of6GwLt9szpIlxx0z2dP3dprg6K3zRU/5bbig+zoVKpfg==" + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.core": { + "type": "Project", + "dependencies": { + "Grpc.Net.Client": "[2.76.0, )", + "System.Security.Cryptography.ProtectedData": "[10.0.3, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )" + } + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "werkr.servicedefaults": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0" + } + }, + "Grpc.Net.ClientFactory": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==", + "dependencies": { + "Grpc.Net.Client": "2.76.0" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.3.0", + "Microsoft.Extensions.Resilience": "10.3.0" + } + }, + "Microsoft.Extensions.ServiceDiscovery": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", + "dependencies": { + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" + } + } + } +} \ No newline at end of file diff --git a/src/Werkr.Api/Authorization/ClaimsPermissionAuthorizationHandler.cs b/src/Werkr.Api/Authorization/ClaimsPermissionAuthorizationHandler.cs new file mode 100644 index 0000000..d2b56b5 --- /dev/null +++ b/src/Werkr.Api/Authorization/ClaimsPermissionAuthorizationHandler.cs @@ -0,0 +1,36 @@ +using System.Security.Claims; + +using Microsoft.AspNetCore.Authorization; + +using Werkr.Common.Auth; + +namespace Werkr.Api.Authorization; + +/// +/// Authorization handler that reads permissions from JWT claims. +/// Used by the API process where the identity DB is not available. +/// Zero DB queries per authorization check. +/// +public sealed class ClaimsPermissionAuthorizationHandler + : AuthorizationHandler { + + /// + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionRequirement requirement ) { + if (context.User.Identity?.IsAuthenticated != true) { + return Task.CompletedTask; + } + + IEnumerable permissionClaims = context.User.FindAll( WerkrClaimTypes.Permission ); + + string requiredPermission = requirement.Permission.ToString( ); + + if (permissionClaims.Any( c => + string.Equals( c.Value, requiredPermission, StringComparison.OrdinalIgnoreCase ) )) { + context.Succeed( requirement ); + } + + return Task.CompletedTask; + } +} diff --git a/src/Werkr.Api/Dockerfile b/src/Werkr.Api/Dockerfile new file mode 100644 index 0000000..4d08f3c --- /dev/null +++ b/src/Werkr.Api/Dockerfile @@ -0,0 +1,106 @@ +# syntax=docker/dockerfile:1 +# ------------------------------------------------------------------- +# Werkr.Api — REST / gRPC API service +# +# Two build modes (controlled by BUILD_MODE arg): +# source (default) — builds from source using the SDK image +# deb — installs pre-built .deb (ServerBundle) from Publish/ +# +# For .deb mode, run publish.ps1 first: +# pwsh scripts/publish.ps1 -Application ServerBundle -Platform linux -Architecture x64 -BuildDebInstallers +# docker compose build --build-arg BUILD_MODE=deb +# +# Build context: repository root (Werkr_Final/) +# ------------------------------------------------------------------- + +ARG BUILD_MODE=source + +# ========================== +# Source build stage +# ========================== +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS source-build +ARG TARGETARCH +WORKDIR /src + +COPY Directory.Build.props Directory.Packages.props global.json Werkr.slnx ./ + +COPY src/Werkr.ServiceDefaults/Werkr.ServiceDefaults.csproj src/Werkr.ServiceDefaults/ +COPY src/Werkr.Common.Configuration/Werkr.Common.Configuration.csproj src/Werkr.Common.Configuration/ +COPY src/Werkr.Common/Werkr.Common.csproj src/Werkr.Common/ +COPY src/Werkr.Core/Werkr.Core.csproj src/Werkr.Core/ +COPY src/Werkr.Data/Werkr.Data.csproj src/Werkr.Data/ +COPY src/Werkr.Data.Identity/Werkr.Data.Identity.csproj src/Werkr.Data.Identity/ +COPY src/Werkr.Api/Werkr.Api.csproj src/Werkr.Api/ + +# Proto files needed during restore/build for gRPC codegen +COPY src/Werkr.Api/Protos/ src/Werkr.Api/Protos/ +COPY src/Werkr.Agent/Protos/ src/Werkr.Agent/Protos/ + +RUN dotnet restore src/Werkr.Api/Werkr.Api.csproj \ + -r linux-${TARGETARCH} + +COPY src/ src/ +RUN dotnet publish src/Werkr.Api/Werkr.Api.csproj \ + -c Release -r linux-${TARGETARCH} -o /app/publish \ + --sc true + +# ========================== +# Source runtime +# ========================== +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS source + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system werkr \ + && useradd --system --no-create-home -g werkr werkr + +WORKDIR /app + +RUN mkdir -p /app/certs \ + && chown -R werkr:werkr /app/certs + +COPY --from=source-build --chown=werkr:werkr /app/publish . + +EXPOSE 8443 + +ENV ASPNETCORE_URLS=https://+:8443 \ + ASPNETCORE_ENVIRONMENT=Production \ + DOTNET_ENVIRONMENT=Production + +USER werkr +ENTRYPOINT ["./Werkr.Api"] + +# ========================== +# Deb runtime +# ========================== +FROM debian:bookworm-slim AS deb + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl libicu72 libssl3 \ + && rm -rf /var/lib/apt/lists/* + +COPY Publish/Werkr.ServerBundle.*.deb /tmp/ +RUN dpkg -x /tmp/*.deb / && rm /tmp/*.deb + +RUN groupadd --system werkr \ + && useradd --system --no-create-home -g werkr werkr + +WORKDIR /opt/werkr/serverbundle + +RUN mkdir -p /opt/werkr/serverbundle/certs \ + && chown -R werkr:werkr /opt/werkr/serverbundle/certs + +USER werkr + +EXPOSE 8443 + +ENV ASPNETCORE_URLS=https://+:8443 \ + ASPNETCORE_ENVIRONMENT=Production \ + DOTNET_ENVIRONMENT=Production + +ENTRYPOINT ["./Werkr.Api"] + +# ========================== +# Final (selects mode via BUILD_MODE arg) +# ========================== +FROM ${BUILD_MODE} AS final diff --git a/src/Werkr.Api/Endpoints/AgentEndpoints.cs b/src/Werkr.Api/Endpoints/AgentEndpoints.cs new file mode 100644 index 0000000..c15fb2d --- /dev/null +++ b/src/Werkr.Api/Endpoints/AgentEndpoints.cs @@ -0,0 +1,517 @@ +using Grpc.Core; +using Microsoft.EntityFrameworkCore; +using Werkr.Common.Auth; +using Werkr.Common.Models; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Cryptography; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +// KeyRotationService used for POST /api/agents/{id}/rotate-key + +namespace Werkr.Api.Endpoints; + +/// Maps all agent-related REST endpoints. +internal static class AgentEndpoints { + /// Maps agent CRUD, health, activity, execute, tags, connection, and key rotation endpoints. + public static WebApplication MapAgentEndpoints( this WebApplication app ) { + MapAgentCrud( app ); + MapAgentHealth( app ); + MapAgentActivity( app ); + MapAgentExecute( app ); + MapAgentTags( app ); + MapAgentConnections( app ); + MapAgentKeyRotation( app ); + return app; + } + + // ── Agent CRUD ── + + private static void MapAgentCrud( WebApplication app ) { + _ = app.MapGet( "/api/agents", async ( WerkrDbContext dbContext, CancellationToken ct ) => { + List connections = await dbContext.RegisteredConnections + .Where( c => c.IsServer ) + .OrderByDescending( c => c.Created ) + .ToListAsync( ct ); + + List agents = [.. connections.Select( c => new AgentListDto( + c.Id, c.ConnectionName, c.RemoteUrl, c.Status.ToString( ), + c.LastSeen, c.Created ) )]; + + return Results.Ok( agents ); + } ) + .WithName( "GetAgents" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapGet( "/api/agents/{id}", async ( + Guid id, + WerkrDbContext dbContext, + AgentConnectionManager connectionManager, + CancellationToken ct ) => { + RegisteredConnection? connection = await dbContext.RegisteredConnections + .AsNoTracking( ) + .FirstOrDefaultAsync( c => c.Id == id && c.IsServer, ct ); + + if (connection is null) { + return Results.NotFound( ); + } + + bool? powerShellAvailable = null; + bool? systemShellAvailable = null; + + if (connection.Status == ConnectionStatus.Connected) { + try { + (Grpc.Net.Client.GrpcChannel channel, RegisteredConnection resolvedConnection) + = await connectionManager.GetChannelAsync( id, ct ); + + string keyId = resolvedConnection.ActiveKeyId ?? resolvedConnection.Id.ToString( ); + HeartbeatRequest heartbeat = new( ) { StatusMessage = "detail-probe" }; + EncryptedEnvelope requestEnvelope = PayloadEncryptor.EncryptToEnvelope( + heartbeat, resolvedConnection.SharedKey, keyId ); + + ConnectionManagement.ConnectionManagementClient client = new( channel ); + EncryptedEnvelope responseEnvelope = await client.HeartbeatAsync( + requestEnvelope, + AgentConnectionManager.CreateCallOptions( + resolvedConnection, + timeout: TimeSpan.FromSeconds( 5 ), + cancellationToken: ct ) ); + + // Agent is reachable and shared key is valid + HeartbeatResponse _ = PayloadEncryptor.DecryptFromEnvelope( + responseEnvelope, resolvedConnection.SharedKey ); + + // Shell availability is no longer reported via health checks; + // the Heartbeat confirms the agent is alive and encryption works. + powerShellAvailable = true; + systemShellAvailable = true; + } catch (RpcException) { + powerShellAvailable = null; + systemShellAvailable = null; + } + } + + byte[] remotePublicKeyBytes = EncryptionProvider.SerializePublicKey( connection.RemotePublicKey ); + string keyString = System.Text.Encoding.UTF8.GetString( remotePublicKeyBytes ); + string fingerprint = EncryptionProvider.ComputeKeyFingerprint( keyString ); + + AgentDetailDto dto = new( + connection.Id, + connection.ConnectionName, + connection.RemoteUrl, + connection.Status.ToString( ), + fingerprint, + connection.Created, + connection.LastSeen, + powerShellAvailable, + systemShellAvailable ); + + return Results.Ok( dto ); + } ) + .WithName( "GetAgentDetail" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapPut( "/api/agents/{id}", async ( + Guid id, + UpdateAgentRequest request, + WerkrDbContext dbContext, + AgentConnectionManager connectionManager, + CancellationToken ct ) => { + if (string.IsNullOrWhiteSpace( request.ConnectionName ) + && string.IsNullOrWhiteSpace( request.RemoteUrl )) { + return Results.BadRequest( "At least one of ConnectionName or RemoteUrl must be provided." ); + } + + RegisteredConnection? connection = await dbContext.RegisteredConnections + .FirstOrDefaultAsync( c => c.Id == id && c.IsServer, ct ); + if (connection is null) { + return Results.NotFound( ); + } + + if (!string.IsNullOrWhiteSpace( request.ConnectionName )) { + string trimmed = request.ConnectionName.Trim( ); + if (trimmed.Length > 200) { + return Results.BadRequest( "Connection name must be 200 characters or fewer." ); + } + connection.ConnectionName = trimmed; + } + + if (!string.IsNullOrWhiteSpace( request.RemoteUrl )) { + string urlTrimmed = request.RemoteUrl.Trim( ); + + if (!Uri.TryCreate( urlTrimmed, UriKind.Absolute, out Uri? parsedUri ) + || (parsedUri.Scheme != "https" && parsedUri.Scheme != "http")) { + return Results.BadRequest( "RemoteUrl must be a valid HTTP or HTTPS URL." ); + } + + string oldUrl = connection.RemoteUrl; + connection.RemoteUrl = urlTrimmed; + + // If the URL changed, remove the cached gRPC channel so it reconnects + if (!string.Equals( oldUrl, urlTrimmed, StringComparison.OrdinalIgnoreCase )) { + connectionManager.RemoveChannel( id ); + } + } + + _ = await dbContext.SaveChangesAsync( ct ); + return Results.NoContent( ); + } ) + .WithName( "UpdateAgent" ) + .RequireAuthorization( Policies.CanUpdate ); + + _ = app.MapPost( "/api/agents/{id}/revoke", async ( + Guid id, + WerkrDbContext dbContext, + AgentConnectionManager connectionManager, + CancellationToken ct ) => { + RegisteredConnection? connection = await dbContext.RegisteredConnections + .FirstOrDefaultAsync( c => c.Id == id, ct ); + + if (connection is null) { + return Results.NotFound( new { message = "Connection not found." } ); + } + + connection.Status = ConnectionStatus.Revoked; + _ = await dbContext.SaveChangesAsync( ct ); + connectionManager.RemoveChannel( id ); + return Results.Ok( new { message = $"Connection '{connection.ConnectionName}' revoked." } ); + } ) + .WithName( "RevokeAgent" ) + .RequireAuthorization( Policies.IsAdmin ); + + _ = app.MapPut( "/api/agents/{id}/status", async ( + Guid id, + UpdateAgentStatusRequest request, + WerkrDbContext dbContext, + CancellationToken ct ) => { + if (!Enum.TryParse( request.Status, ignoreCase: true, out ConnectionStatus newStatus )) { + return Results.BadRequest( new { message = $"Invalid status: {request.Status}" } ); + } + + RegisteredConnection? connection = await dbContext.RegisteredConnections + .FirstOrDefaultAsync( c => c.Id == id && c.IsServer, ct ); + if (connection is null) { + return Results.NotFound( ); + } + + // Do not overwrite Revoked status — that requires explicit admin action via /revoke + if (connection.Status == ConnectionStatus.Revoked) { + return Results.Ok( new { status = connection.Status.ToString( ) } ); + } + + connection.Status = newStatus; + if (newStatus == ConnectionStatus.Connected) { + connection.LastSeen = DateTime.UtcNow; + } + _ = await dbContext.SaveChangesAsync( ct ); + return Results.Ok( new { status = connection.Status.ToString( ) } ); + } ) + .WithName( "UpdateAgentStatus" ) + .RequireAuthorization( Policies.CanUpdate ); + } + + // ── Agent Health ── + + private static void MapAgentHealth( WebApplication app ) { + _ = app.MapGet( "/api/agents/health", async ( + WerkrDbContext dbContext, + AgentConnectionManager connectionManager, + CancellationToken ct ) => { + List connections = await dbContext.RegisteredConnections + .AsNoTracking( ) + .Where( c => c.IsServer ) + .OrderBy( c => c.ConnectionName ) + .ToListAsync( ct ); + + using CancellationTokenSource timeoutSource = new( TimeSpan.FromSeconds( 10 ) ); + using CancellationTokenSource linkedSource = CancellationTokenSource + .CreateLinkedTokenSource( ct, timeoutSource.Token ); + + List> tasks = [.. connections.Select( + connection => BuildHealthAsync( connection, connectionManager, linkedSource.Token ) )]; + + try { + AgentHealthDto[] results = await Task.WhenAll( tasks ); + return Results.Ok( results.ToList( ) ); + } catch (OperationCanceledException) { + List partial = [.. tasks + .Where( task => task.IsCompletedSuccessfully ) + .Select( task => task.Result )]; + return Results.Ok( partial ); + } + } ) + .WithName( "GetAgentHealth" ) + .RequireAuthorization( Policies.CanRead ); + } + + // ── Agent Activity ── + + private static void MapAgentActivity( WebApplication app ) { + _ = app.MapGet( "/api/agents/activity", async ( + int? count, + WerkrDbContext dbContext, + CancellationToken ct ) => { + int effectiveCount = Math.Clamp( count ?? 10, 1, 100 ); + + List connections = await dbContext.RegisteredConnections + .AsNoTracking( ) + .Where( c => c.IsServer ) + .ToListAsync( ct ); + + List events = []; + foreach (RegisteredConnection connection in connections) { + events.Add( new AgentActivityDto( + connection.Id, + connection.ConnectionName, + "Registered", + connection.Created, + connection.Status.ToString( ) ) ); + + if (connection.LastSeen.HasValue) { + events.Add( new AgentActivityDto( + connection.Id, + connection.ConnectionName, + "Last Seen", + connection.LastSeen.Value, + connection.Status.ToString( ) ) ); + } + + if (connection.Status == ConnectionStatus.Revoked) { + events.Add( new AgentActivityDto( + connection.Id, + connection.ConnectionName, + "Revoked", + connection.LastUpdated, + connection.Status.ToString( ) ) ); + } + } + + List timeline = [.. events + .OrderByDescending( e => e.OccurredAtUtc ) + .Take( effectiveCount )]; + + return Results.Ok( timeline ); + } ) + .WithName( "GetAgentActivity" ) + .RequireAuthorization( Policies.CanRead ); + } + + // ── Agent Execute ── + + private static void MapAgentExecute( WebApplication app ) { + _ = app.MapPost( "/api/agents/{agentId}/execute", async ( + Guid agentId, + ExecuteCommandRequest request, + CommandDispatcher commandDispatcher, + CancellationToken ct ) => { + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource( ct ); + if (request.TimeoutMinutes > 0) { + cts.CancelAfter( TimeSpan.FromMinutes( request.TimeoutMinutes ) ); + } + + OperatorType operatorType = Enum.Parse( request.OperatorType, ignoreCase: true ); + List results = []; + + await foreach (OperatorOutput output in commandDispatcher.ExecuteCommandAsync( + agentId, operatorType, request.Command, cts.Token )) { + results.Add( new OperatorOutputLine( output.LogLevel, output.Message, output.Timestamp ) ); + } + + return Results.Ok( new ExecuteCommandResponse( true, results ) ); + } ) + .WithName( "ExecuteCommand" ) + .RequireAuthorization( Policies.CanExecute ); + } + + // ── Agent Tags ── + + private static void MapAgentTags( WebApplication app ) { + _ = app.MapGet( "/api/tags", async ( + WerkrDbContext dbContext, + CancellationToken ct ) => { + // Aggregate all unique tags across registered agents and tasks + List agentTags = await dbContext.RegisteredConnections + .AsNoTracking( ) + .Where( c => c.IsServer ) + .Select( c => c.Tags ) + .ToListAsync( ct ); + + List taskTags = await dbContext.Tasks + .AsNoTracking( ) + .Select( t => t.TargetTags ) + .ToListAsync( ct ); + + List allTags = [.. agentTags + .Concat( taskTags ) + .SelectMany( t => t ) + .Where( t => !string.IsNullOrWhiteSpace( t ) ) + .Select( t => t.Trim( ) ) + .Distinct( StringComparer.OrdinalIgnoreCase ) + .OrderBy( t => t, StringComparer.OrdinalIgnoreCase )]; + + return Results.Ok( allTags ); + } ) + .WithName( "GetAllTags" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapGet( "/api/agents/{id}/tags", async ( + Guid id, + WerkrDbContext dbContext, + CancellationToken ct ) => { + RegisteredConnection? connection = await dbContext.RegisteredConnections + .AsNoTracking( ) + .FirstOrDefaultAsync( c => c.Id == id && c.IsServer, ct ); + return connection is null ? Results.NotFound( ) : Results.Ok( connection.Tags ); + } ) + .WithName( "GetAgentTags" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapPut( "/api/agents/{id}/tags", async ( + Guid id, + UpdateAgentTagsRequest request, + WerkrDbContext dbContext, + CancellationToken ct ) => { + RegisteredConnection? connection = await dbContext.RegisteredConnections + .FirstOrDefaultAsync( c => c.Id == id && c.IsServer, ct ); + if (connection is null) { + return Results.NotFound( ); + } + + connection.Tags = request.Tags; + _ = await dbContext.SaveChangesAsync( ct ); + return Results.Ok( connection.Tags ); + } ) + .WithName( "UpdateAgentTags" ) + .RequireAuthorization( Policies.CanUpdate ); + } + + // ── Agent Connections (read-only metadata for Server UI) ── + + private static void MapAgentConnections( WebApplication app ) { + _ = app.MapGet( "/api/agents/connections", async ( + WerkrDbContext dbContext, + CancellationToken ct ) => { + List connections = await dbContext.RegisteredConnections + .AsNoTracking( ) + .Where( c => c.IsServer ) + .OrderBy( c => c.ConnectionName ) + .ToListAsync( ct ); + + List dtos = [.. connections.Select( c => new AgentConnectionDto( + c.Id, c.ConnectionName, c.RemoteUrl, c.Status.ToString( ), + c.LastSeen, c.Created, c.Tags ) )]; + + return Results.Ok( dtos ); + } ) + .WithName( "GetAgentConnections" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapGet( "/api/agents/connections/{id}", async ( + Guid id, + WerkrDbContext dbContext, + CancellationToken ct ) => { + RegisteredConnection? connection = await dbContext.RegisteredConnections + .AsNoTracking( ) + .FirstOrDefaultAsync( c => c.Id == id && c.IsServer, ct ); + + if (connection is null) { + return Results.NotFound( ); + } + + AgentConnectionDto dto = new( + connection.Id, connection.ConnectionName, connection.RemoteUrl, + connection.Status.ToString( ), connection.LastSeen, connection.Created, + connection.Tags ); + + return Results.Ok( dto ); + } ) + .WithName( "GetAgentConnection" ) + .RequireAuthorization( Policies.CanRead ); + } + + // ── Key Rotation ── + + private static void MapAgentKeyRotation( WebApplication app ) { + _ = app.MapPost( "/api/agents/{id}/rotate-key", async ( + Guid id, + KeyRotationService keyRotationService, + CancellationToken ct ) => { + bool success = await keyRotationService.RotateSingleAgentAsync( id, ct ); + return success + ? Results.Ok( new { message = "Key rotation completed successfully." } ) + : Results.UnprocessableEntity( new { message = "Key rotation failed. Agent may be unreachable or not connected." } ); + } ) + .WithName( "RotateAgentKey" ) + .RequireAuthorization( Policies.IsAdmin ); + } + + // ── Helper ── + + private static async Task BuildHealthAsync( + RegisteredConnection connection, + AgentConnectionManager connectionManager, + CancellationToken cancellationToken ) { + // Skip Revoked agents entirely — they should never reconnect without explicit admin action. + if (connection.Status == ConnectionStatus.Revoked) { + return new AgentHealthDto( + connection.Id, + connection.ConnectionName, + connection.Status.ToString( ), + null, + null, + connection.LastSeen, + DateTime.UtcNow ); + } + + try { + (Grpc.Net.Client.GrpcChannel channel, RegisteredConnection resolvedConnection) + = await connectionManager.GetChannelAsync( connection.Id, cancellationToken ); + + string keyId = resolvedConnection.ActiveKeyId ?? resolvedConnection.Id.ToString( ); + HeartbeatRequest heartbeat = new( ) { StatusMessage = "health-probe" }; + EncryptedEnvelope requestEnvelope = PayloadEncryptor.EncryptToEnvelope( + heartbeat, resolvedConnection.SharedKey, keyId ); + + ConnectionManagement.ConnectionManagementClient client = new( channel ); + EncryptedEnvelope responseEnvelope = await client.HeartbeatAsync( + requestEnvelope, + AgentConnectionManager.CreateCallOptions( + resolvedConnection, + timeout: TimeSpan.FromSeconds( 5 ), + cancellationToken: cancellationToken ) ); + + HeartbeatResponse _ = PayloadEncryptor.DecryptFromEnvelope( + responseEnvelope, resolvedConnection.SharedKey ); + + return new AgentHealthDto( + connection.Id, + connection.ConnectionName, + "Connected", + true, + true, + connection.LastSeen, + DateTime.UtcNow ); + } catch (OperationCanceledException) { + throw; + } catch (RpcException) { + return new AgentHealthDto( + connection.Id, + connection.ConnectionName, + "Unreachable", + null, + null, + connection.LastSeen, + DateTime.UtcNow ); + } catch (Exception) { + return new AgentHealthDto( + connection.Id, + connection.ConnectionName, + "Unreachable", + null, + null, + connection.LastSeen, + DateTime.UtcNow ); + } + } +} diff --git a/src/Werkr.Api/Endpoints/AuthProxyEndpoints.cs b/src/Werkr.Api/Endpoints/AuthProxyEndpoints.cs new file mode 100644 index 0000000..b495feb --- /dev/null +++ b/src/Werkr.Api/Endpoints/AuthProxyEndpoints.cs @@ -0,0 +1,47 @@ +using Werkr.Common.Models; + +namespace Werkr.Api.Endpoints; + +/// +/// Maps the token proxy endpoint on the API. The API does NOT issue tokens — +/// it forwards to the Server (the sole JWT issuer) +/// and returns the verbatim (Decision A14). +/// +public static class AuthProxyEndpoints { + /// + /// Maps POST /api/auth/token as a transparent pass-through to the Server. + /// + public static WebApplication MapAuthProxyEndpoints( this WebApplication app ) { + _ = app.MapPost( "/api/auth/token", async ( + TokenRequest request, + IHttpClientFactory httpClientFactory, + CancellationToken ct ) => { + if (string.IsNullOrWhiteSpace( request.ApiKey )) { + return Results.BadRequest( new { message = "API key is required." } ); + } + + // Use the raw server client — no auth header (user is requesting a token) + using HttpClient serverClient = httpClientFactory.CreateClient( "ServerService" ); + + using HttpResponseMessage response = await serverClient.PostAsJsonAsync( + "/api/auth/token", request, ct ); + + if (!response.IsSuccessStatusCode) { + // Forward the Server's status code (e.g. 401 for invalid key) + return Results.StatusCode( (int)response.StatusCode ); + } + + TokenResponse? tokenResponse = await response.Content + .ReadFromJsonAsync( ct ); + + return tokenResponse is null + ? Results.Problem( "Token exchange returned an empty response." ) + : Results.Ok( tokenResponse ); + } ) + .WithName( "ProxyTokenExchange" ) + .WithTags( "Auth" ) + .AllowAnonymous( ); + + return app; + } +} diff --git a/src/Werkr.Api/Endpoints/DiagnosticsEndpoints.cs b/src/Werkr.Api/Endpoints/DiagnosticsEndpoints.cs new file mode 100644 index 0000000..9606f8e --- /dev/null +++ b/src/Werkr.Api/Endpoints/DiagnosticsEndpoints.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Werkr.Common.Auth; +using Werkr.Common.Models; +using Werkr.Data; + +namespace Werkr.Api.Endpoints; + +/// Maps the diagnostics health endpoint. +internal static class DiagnosticsEndpoints { + /// Maps GET /api/diagnostics/health. + public static WebApplication MapDiagnosticsEndpoints( this WebApplication app ) { + _ = app.MapGet( "/api/diagnostics/health", async ( + WerkrDbContext appDbContext, + CancellationToken ct ) => { + List diagnostics = []; + + bool appConnected = await appDbContext.Database.CanConnectAsync( ct ); + List appPending = [.. await appDbContext.Database.GetPendingMigrationsAsync( ct )]; + List appApplied = [.. await appDbContext.Database.GetAppliedMigrationsAsync( ct )]; + + diagnostics.Add( new DatabaseHealthDto( + "Application", + appDbContext.Database.ProviderName ?? "Unknown", + appConnected, + appApplied.Count, + appPending.Count, + appPending ) ); + + return Results.Ok( diagnostics ); + } ) + .WithName( "GetDiagnosticsHealth" ) + .RequireAuthorization( Policies.CanRead ); + + return app; + } +} diff --git a/src/Werkr.Api/Endpoints/HolidayCalendarEndpoints.cs b/src/Werkr.Api/Endpoints/HolidayCalendarEndpoints.cs new file mode 100644 index 0000000..7c6184f --- /dev/null +++ b/src/Werkr.Api/Endpoints/HolidayCalendarEndpoints.cs @@ -0,0 +1,514 @@ +using System.ComponentModel.DataAnnotations; + +using Microsoft.EntityFrameworkCore; + +using Werkr.Api.Models; +using Werkr.Api.Services; +using Werkr.Common.Auth; +using Werkr.Common.Models.Holidays; +using Werkr.Core.Scheduling; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Api.Endpoints; + +/// Maps all holiday-calendar REST endpoints (calendar CRUD, rule CRUD, date CRUD, preview, attachment, audit). +internal static class HolidayCalendarEndpoints { + /// Maps the 25 holiday-calendar endpoints. + public static WebApplication MapHolidayCalendarEndpoints( this WebApplication app ) { + + // ── Calendar CRUD (6) ────────────────────────────────────────────────── + + // 1. GET /api/holiday-calendars + _ = app.MapGet( "/api/holiday-calendars", async ( + HolidayCalendarService service, + CancellationToken ct ) => { + IReadOnlyList calendars = await service.GetAllAsync( ct ); + List dtos = [.. calendars.Select( c => HolidayCalendarMapper.ToSummaryDto( c, c.ScheduleLinks?.Count ?? 0 ) )]; + return Results.Ok( dtos ); + } ) + .WithName( "GetHolidayCalendars" ) + .RequireAuthorization( Policies.CanRead ); + + // 2. GET /api/holiday-calendars/{id} + _ = app.MapGet( "/api/holiday-calendars/{id}", async ( + Guid id, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + HolidayCalendar calendar = await service.GetByIdAsync( id, ct ) + ?? throw new KeyNotFoundException( ); + return Results.Ok( HolidayCalendarMapper.ToDto( calendar ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "GetHolidayCalendar" ) + .RequireAuthorization( Policies.CanRead ); + + // 3. POST /api/holiday-calendars + _ = app.MapPost( "/api/holiday-calendars", async ( + HolidayCalendarCreateRequest request, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + HolidayCalendar entity = HolidayCalendarMapper.ToEntity( request ); + HolidayCalendar created = await service.CreateAsync( entity, ct ); + HolidayCalendarDto dto = HolidayCalendarMapper.ToDto( created ); + return Results.Created( $"/api/holiday-calendars/{dto.Id}", dto ); + } catch (ValidationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "CreateHolidayCalendar" ) + .RequireAuthorization( Policies.CanCreate ); + + // 4. PUT /api/holiday-calendars/{id} + _ = app.MapPut( "/api/holiday-calendars/{id}", async ( + Guid id, + HolidayCalendarUpdateRequest request, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + HolidayCalendar existing = await service.GetByIdAsync( id, ct ) + ?? throw new KeyNotFoundException( ); + existing.Name = request.Name; + existing.Description = request.Description; + HolidayCalendar updated = await service.UpdateAsync( id, existing, ct ); + return Results.Ok( HolidayCalendarMapper.ToDto( updated ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (InvalidOperationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "UpdateHolidayCalendar" ) + .RequireAuthorization( Policies.CanUpdate ); + + // 5. DELETE /api/holiday-calendars/{id} + _ = app.MapDelete( "/api/holiday-calendars/{id}", async ( + Guid id, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + await service.DeleteAsync( id, ct ); + return Results.NoContent( ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (InvalidOperationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "DeleteHolidayCalendar" ) + .RequireAuthorization( Policies.CanDelete ); + + // 6. POST /api/holiday-calendars/{id}/clone + _ = app.MapPost( "/api/holiday-calendars/{id}/clone", async ( + Guid id, + CloneHolidayCalendarRequest request, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + HolidayCalendar cloned = await service.CloneAsync( id, request.NewName, ct ); + HolidayCalendarDto dto = HolidayCalendarMapper.ToDto( cloned ); + return Results.Created( $"/api/holiday-calendars/{dto.Id}", dto ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "CloneHolidayCalendar" ) + .RequireAuthorization( Policies.CanCreate ); + + // ── Rule CRUD (6) ────────────────────────────────────────────────────── + + // 7. GET /api/holiday-calendars/{id}/rules + _ = app.MapGet( "/api/holiday-calendars/{id}/rules", async ( + Guid id, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + IReadOnlyList rules = await service.GetRulesAsync( id, ct ); + List dtos = [.. rules.Select( HolidayCalendarMapper.ToDto )]; + return Results.Ok( dtos ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "GetHolidayRules" ) + .RequireAuthorization( Policies.CanRead ); + + // 8. GET /api/holiday-calendars/{id}/rules/{ruleId} + _ = app.MapGet( "/api/holiday-calendars/{id}/rules/{ruleId}", async ( + Guid id, + long ruleId, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + HolidayRule rule = await service.GetRuleAsync( id, ruleId, ct ) + ?? throw new KeyNotFoundException( ); + return Results.Ok( HolidayCalendarMapper.ToDto( rule ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "GetHolidayRule" ) + .RequireAuthorization( Policies.CanRead ); + + // 9. POST /api/holiday-calendars/{id}/rules + _ = app.MapPost( "/api/holiday-calendars/{id}/rules", async ( + Guid id, + HolidayRuleCreateRequest request, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + HolidayRule entity = HolidayCalendarMapper.ToEntity( request ); + HolidayRule created = await service.AddRuleAsync( id, entity, ct ); + HolidayRuleDto dto = HolidayCalendarMapper.ToDto( created ); + return Results.Created( $"/api/holiday-calendars/{id}/rules/{dto.Id}", dto ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (ValidationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } catch (InvalidOperationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "CreateHolidayRule" ) + .RequireAuthorization( Policies.CanCreate ); + + // 10. PUT /api/holiday-calendars/{id}/rules/{ruleId} + _ = app.MapPut( "/api/holiday-calendars/{id}/rules/{ruleId}", async ( + Guid id, + long ruleId, + HolidayRuleUpdateRequest request, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + HolidayRule entity = HolidayCalendarMapper.ToEntity( request ); + HolidayRule updated = await service.UpdateRuleAsync( id, ruleId, entity, ct ); + return Results.Ok( HolidayCalendarMapper.ToDto( updated ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (ValidationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } catch (InvalidOperationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "UpdateHolidayRule" ) + .RequireAuthorization( Policies.CanUpdate ); + + // 11. DELETE /api/holiday-calendars/{id}/rules/{ruleId} + _ = app.MapDelete( "/api/holiday-calendars/{id}/rules/{ruleId}", async ( + Guid id, + long ruleId, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + await service.RemoveRuleAsync( id, ruleId, ct ); + return Results.NoContent( ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (InvalidOperationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "DeleteHolidayRule" ) + .RequireAuthorization( Policies.CanDelete ); + + // 12. POST /api/holiday-calendars/rules/preview?startYear=&endYear= + _ = app.MapPost( "/api/holiday-calendars/rules/preview", ( + int startYear, + int endYear, + RulePreviewRequest request ) => { + HolidayRule rule = HolidayCalendarMapper.ToEntity( request ); + IReadOnlyList dates = HolidayCalculator.ComputeDatesForRange( + new HolidayCalendar { Rules = [rule] }, startYear, endYear ); + List dtos = [.. dates.Select( HolidayCalendarMapper.ToDto )]; + return Results.Ok( new RulePreviewResponse( startYear, endYear, dtos ) ); + } ) + .WithName( "PreviewHolidayRule" ) + .RequireAuthorization( Policies.CanRead ); + + // ── Manual Date CRUD (6) ─────────────────────────────────────────────── + + // 13. GET /api/holiday-calendars/{id}/dates + _ = app.MapGet( "/api/holiday-calendars/{id}/dates", async ( + Guid id, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + IReadOnlyList dates = await service.GetManualDatesAsync( id, ct ); + List dtos = [.. dates.Select( HolidayCalendarMapper.ToDto )]; + return Results.Ok( dtos ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "GetHolidayDates" ) + .RequireAuthorization( Policies.CanRead ); + + // 14. GET /api/holiday-calendars/{id}/dates/{dateId} + _ = app.MapGet( "/api/holiday-calendars/{id}/dates/{dateId}", async ( + Guid id, + long dateId, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + HolidayDate date = await service.GetManualDateAsync( id, dateId, ct ) + ?? throw new KeyNotFoundException( ); + return Results.Ok( HolidayCalendarMapper.ToDto( date ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "GetHolidayDate" ) + .RequireAuthorization( Policies.CanRead ); + + // 15. POST /api/holiday-calendars/{id}/dates + _ = app.MapPost( "/api/holiday-calendars/{id}/dates", async ( + Guid id, + HolidayDateCreateRequest request, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + HolidayDate entity = HolidayCalendarMapper.ToEntity( request ); + HolidayDate created = await service.AddManualDateAsync( id, entity, ct ); + HolidayDateDto dto = HolidayCalendarMapper.ToDto( created ); + return Results.Created( $"/api/holiday-calendars/{id}/dates/{dto.Id}", dto ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (InvalidOperationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "CreateHolidayDate" ) + .RequireAuthorization( Policies.CanCreate ); + + // 16. PUT /api/holiday-calendars/{id}/dates/{dateId} + _ = app.MapPut( "/api/holiday-calendars/{id}/dates/{dateId}", async ( + Guid id, + long dateId, + HolidayDateCreateRequest request, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + HolidayDate entity = HolidayCalendarMapper.ToEntity( request ); + HolidayDate updated = await service.UpdateManualDateAsync( id, dateId, entity, ct ); + return Results.Ok( HolidayCalendarMapper.ToDto( updated ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (InvalidOperationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "UpdateHolidayDate" ) + .RequireAuthorization( Policies.CanUpdate ); + + // 17. DELETE /api/holiday-calendars/{id}/dates/{dateId} + _ = app.MapDelete( "/api/holiday-calendars/{id}/dates/{dateId}", async ( + Guid id, + long dateId, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + await service.RemoveManualDateAsync( id, dateId, ct ); + return Results.NoContent( ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (InvalidOperationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "DeleteHolidayDate" ) + .RequireAuthorization( Policies.CanDelete ); + + // 18. POST /api/holiday-calendars/{id}/dates/bulk + _ = app.MapPost( "/api/holiday-calendars/{id}/dates/bulk", async ( + Guid id, + BulkHolidayDateCreateRequest request, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + List entities = [.. request.Dates.Select( HolidayCalendarMapper.ToEntity )]; + IReadOnlyList created = await service.BulkAddManualDatesAsync( id, entities, ct ); + List dtos = [.. created.Select( HolidayCalendarMapper.ToDto )]; + return Results.Created( $"/api/holiday-calendars/{id}/dates", dtos ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (InvalidOperationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "BulkCreateHolidayDates" ) + .RequireAuthorization( Policies.CanCreate ); + + // ── Calendar Preview (1) ─────────────────────────────────────────────── + + // 19. GET /api/holiday-calendars/{id}/preview?startYear=&endYear= + _ = app.MapGet( "/api/holiday-calendars/{id}/preview", async ( + Guid id, + int startYear, + int endYear, + HolidayCalendarService service, + HolidayDateService dateService, + CancellationToken ct ) => { + try { + HolidayCalendar? cal = await service.GetByIdAsync( id, ct ); + if (cal is null) { + return Results.NotFound( ); + } + + IReadOnlyList dates = await dateService.GetDatesForRangeAsync( + id, + new DateOnly( startYear, 1, 1 ), + new DateOnly( endYear, 12, 31 ), + ct ); + List dtos = [.. dates.Select( HolidayCalendarMapper.ToDto )]; + return Results.Ok( new HolidayPreviewResponse( id, startYear, endYear, dtos ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "PreviewHolidayCalendar" ) + .RequireAuthorization( Policies.CanRead ); + + // ── Schedule Attachment (3) ──────────────────────────────────────────── + + // 20. GET /api/schedules/{id}/holiday-calendar + _ = app.MapGet( "/api/schedules/{id}/holiday-calendar", async ( + Guid id, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + ScheduleHolidayCalendar? link = await service.GetScheduleCalendarAsync( id, ct ); + if (link is null) { + return Results.NoContent( ); + } + + HolidayCalendar? calendar = await service.GetByIdAsync( link.HolidayCalendarId, ct ); + string calName = calendar?.Name ?? "Unknown"; + return Results.Ok( new ScheduleHolidayCalendarDto( + link.HolidayCalendarId, calName, link.Mode.ToString( ) ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "GetScheduleHolidayCalendar" ) + .RequireAuthorization( Policies.CanRead ); + + // 21. PUT /api/schedules/{id}/holiday-calendar + _ = app.MapPut( "/api/schedules/{id}/holiday-calendar", async ( + Guid id, + AttachHolidayCalendarRequest request, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + HolidayCalendarMode mode = Enum.Parse( request.Mode, ignoreCase: true ); + _ = await service.AttachToScheduleAsync( id, request.CalendarId, mode, ct ); + + HolidayCalendar? calendar = await service.GetByIdAsync( request.CalendarId, ct ); + string calName = calendar?.Name ?? "Unknown"; + return Results.Ok( new ScheduleHolidayCalendarDto( + request.CalendarId, calName, mode.ToString( ) ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "AttachHolidayCalendar" ) + .RequireAuthorization( Policies.CanUpdate ); + + // 22. DELETE /api/schedules/{id}/holiday-calendar + _ = app.MapDelete( "/api/schedules/{id}/holiday-calendar", async ( + Guid id, + HolidayCalendarService service, + CancellationToken ct ) => { + try { + await service.DetachFromScheduleAsync( id, ct ); + return Results.NoContent( ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "DetachHolidayCalendar" ) + .RequireAuthorization( Policies.CanUpdate ); + + // ── Agent Holiday Data (1) ───────────────────────────────────────────── + + // 23. GET /api/schedules/{id}/holiday-dates?start=&end= + _ = app.MapGet( "/api/schedules/{id}/holiday-dates", async ( + Guid id, + DateOnly start, + DateOnly end, + HolidayCalendarService calService, + HolidayDateService dateService, + CancellationToken ct ) => { + try { + ScheduleHolidayCalendar? link = await calService.GetScheduleCalendarAsync( id, ct ); + if (link is null) { + return Results.NoContent( ); + } + + IReadOnlyList dates = await dateService.GetDatesForRangeAsync( + link.HolidayCalendarId, start, end, ct ); + List dtos = [.. dates.Select( HolidayCalendarMapper.ToDto )]; + return Results.Ok( new ScheduleHolidayDatesResponse( + id, link.HolidayCalendarId, link.Mode.ToString( ), dtos ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "GetScheduleHolidayDates" ) + .RequireAuthorization( Policies.CanRead ); + + // ── Audit Log (2) ────────────────────────────────────────────────────── + + // 24. POST /api/schedules/{id}/audit-log + _ = app.MapPost( "/api/schedules/{id}/audit-log", async ( + Guid id, + ScheduleAuditLogCreateRequest request, + HolidayCalendarService calService, + Data.WerkrDbContext db, + CancellationToken ct ) => { + try { + ScheduleHolidayCalendar? link = await calService.GetScheduleCalendarAsync( id, ct ); + if (link is null) { + return Results.BadRequest( new { message = "No holiday calendar attached." } ); + } + + HolidayCalendar? calendar = await calService.GetByIdAsync( link.HolidayCalendarId, ct ); + string calName = calendar?.Name ?? "Unknown"; + + ScheduleAuditLog log = HolidayCalendarMapper.ToAuditLog( request, id, calName, link.Mode ); + _ = db.ScheduleAuditLogs.Add( log ); + _ = await db.SaveChangesAsync( ct ); + + ScheduleAuditLogDto dto = HolidayCalendarMapper.ToDto( log ); + return Results.Created( $"/api/schedules/{id}/audit-log", dto ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "CreateScheduleAuditLog" ) + .RequireAuthorization( Policies.CanCreate ); + + // 25. GET /api/schedules/{id}/audit-log?from=&to= + _ = app.MapGet( "/api/schedules/{id}/audit-log", async ( + Guid id, + DateTime from, + DateTime to, + Data.WerkrDbContext db, + CancellationToken ct ) => { + List logs = await db.ScheduleAuditLogs + .Where( l => l.ScheduleId == id && l.CreatedUtc >= from && l.CreatedUtc <= to ) + .OrderByDescending( l => l.CreatedUtc ) + .ToListAsync( ct ); + List dtos = [.. logs.Select( HolidayCalendarMapper.ToDto )]; + return Results.Ok( dtos ); + } ) + .WithName( "GetScheduleAuditLog" ) + .RequireAuthorization( Policies.CanRead ); + + return app; + } +} diff --git a/src/Werkr.Api/Endpoints/JobEndpoints.cs b/src/Werkr.Api/Endpoints/JobEndpoints.cs new file mode 100644 index 0000000..2079e4d --- /dev/null +++ b/src/Werkr.Api/Endpoints/JobEndpoints.cs @@ -0,0 +1,118 @@ +using Grpc.Net.Client; +using Werkr.Api.Models; +using Werkr.Common.Auth; +using Werkr.Common.Models; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Tasks; +using Werkr.Data; +using Werkr.Data.Entities.Registration; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Api.Endpoints; + +/// Maps all job-related REST endpoints. +internal static class JobEndpoints { + /// Maps job history, list, detail, and output endpoints. + public static WebApplication MapJobEndpoints( this WebApplication app ) { + _ = app.MapGet( "/api/tasks/{taskId}/jobs", async ( + long taskId, + int? limit, + JobExecutionService jobExecutionService, + CancellationToken ct ) => { + int effectiveLimit = Math.Clamp( limit ?? 50, 1, 500 ); + IReadOnlyList jobs = await jobExecutionService.GetJobHistoryAsync( taskId, effectiveLimit, ct ); + List dtos = [.. jobs.Select( TaskMapper.ToJobListDto )]; + return Results.Ok( dtos ); + } ) + .WithName( "GetJobHistory" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapGet( "/api/jobs", async ( + bool? success, + DateTime? since, + DateTime? until, + int? limit, + JobExecutionService jobExecutionService, + CancellationToken ct ) => { + int effectiveLimit = Math.Clamp( limit ?? 50, 1, 500 ); + IReadOnlyList jobs = await jobExecutionService.GetRecentJobsAsync( + success, since, until, effectiveLimit, ct ); + List dtos = [.. jobs.Select( TaskMapper.ToJobListDto )]; + return Results.Ok( dtos ); + } ) + .WithName( "GetJobs" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapGet( "/api/jobs/{id}", async ( + Guid id, + JobExecutionService jobExecutionService, + CancellationToken ct ) => { + WerkrJob? job = await jobExecutionService.GetJobAsync( id, ct ); + return job is null ? Results.NotFound( ) : Results.Ok( TaskMapper.ToJobDto( job ) ); + } ) + .WithName( "GetJob" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapGet( "/api/jobs/{id}/output", async ( + Guid id, + JobExecutionService jobExecutionService, + WerkrDbContext dbContext, + AgentConnectionManager connectionManager, + CancellationToken ct ) => { + // Try local file first (for API-local execution or cached output) + string? output = await jobExecutionService.GetJobOutputAsync( id, ct ); + if (output is not null) { + return Results.Text( output, "text/plain" ); + } + + // Local file not found — fetch from Agent via OutputFetch gRPC + WerkrJob? job = await jobExecutionService.GetJobAsync( id, ct ); + if (job is null) { + return Results.NotFound( new { message = "Job not found." } ); + } + + if (job.AgentConnectionId is null) { + return Results.NotFound( new { message = "No output file found for this job." } ); + } + + try { + (GrpcChannel channel, RegisteredConnection connection) = + await connectionManager.GetChannelAsync( job.AgentConnectionId.Value, ct ); + + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + GetJobOutputRequest grpcRequest = new( ) { + JobId = id.ToString( ), + OutputPath = job.OutputPath ?? string.Empty, + }; + + EncryptedEnvelope requestEnvelope = PayloadEncryptor.EncryptToEnvelope( + grpcRequest, connection.SharedKey, keyId ); + + OutputFetch.OutputFetchClient client = new( channel ); + EncryptedEnvelope responseEnvelope = await client.GetJobOutputAsync( + requestEnvelope, + AgentConnectionManager.CreateCallOptions( + connection, + timeout: TimeSpan.FromSeconds( 30 ), + cancellationToken: ct ) ); + + GetJobOutputResponse grpcResponse = PayloadEncryptor.DecryptFromEnvelope( + responseEnvelope, connection.SharedKey ); + + return grpcResponse.Found + ? Results.Text( grpcResponse.Content, "text/plain" ) + : Results.NotFound( new { message = grpcResponse.Error } ); + } catch (InvalidOperationException) { + // Agent connection not found or revoked + return Results.NotFound( new { message = "Agent unavailable. Cannot retrieve job output." } ); + } catch (Grpc.Core.RpcException) { + return Results.StatusCode( 502 ); + } + } ) + .WithName( "GetJobOutput" ) + .RequireAuthorization( Policies.CanRead ); + + return app; + } +} diff --git a/src/Werkr.Api/Endpoints/RegistrationEndpoints.cs b/src/Werkr.Api/Endpoints/RegistrationEndpoints.cs new file mode 100644 index 0000000..15750ff --- /dev/null +++ b/src/Werkr.Api/Endpoints/RegistrationEndpoints.cs @@ -0,0 +1,37 @@ +using Werkr.Common.Auth; +using Werkr.Common.Models; +using Werkr.Core.Registration; + +namespace Werkr.Api.Endpoints; + +/// Maps the registration bundle generation endpoint. +internal static class RegistrationEndpoints { + /// Maps POST /api/registration/generate. + public static WebApplication MapRegistrationEndpoints( this WebApplication app ) { + _ = app.MapPost( "/api/registration/generate", async ( + RegistrationGenerateRequest request, + RegistrationService registrationService, + CancellationToken ct ) => { + int? expirationMinutes = request.ExpirationMinutes; + TimeSpan? expiration = expirationMinutes switch { + null => null, + <= 0 => TimeSpan.Zero, + _ => TimeSpan.FromMinutes( expirationMinutes.Value ) + }; + + string bundle = await registrationService.GenerateBundleAsync( + request.ConnectionName, request.Password, expiration, request.Tags, ct ); + + RegistrationGenerateResponse response = new( + Success: true, + EncryptedBundle: bundle, + Message: "Bundle generated." ); + + return Results.Ok( response ); + } ) + .WithName( "GenerateRegistrationBundle" ) + .RequireAuthorization( Policies.IsAdmin ); + + return app; + } +} diff --git a/src/Werkr.Api/Endpoints/ScheduleEndpoints.cs b/src/Werkr.Api/Endpoints/ScheduleEndpoints.cs new file mode 100644 index 0000000..6e6905e --- /dev/null +++ b/src/Werkr.Api/Endpoints/ScheduleEndpoints.cs @@ -0,0 +1,127 @@ +using Werkr.Api.Models; +using Werkr.Api.Services; +using Werkr.Common.Auth; +using Werkr.Common.Models; +using Werkr.Common.Models.Holidays; +using Werkr.Core.Scheduling; + +namespace Werkr.Api.Endpoints; + +/// Maps all schedule-related REST endpoints. +internal static class ScheduleEndpoints { + /// Maps schedule CRUD + occurrence-preview endpoints. + public static WebApplication MapScheduleEndpoints( this WebApplication app ) { + _ = app.MapGet( "/api/schedules", async ( + ScheduleService scheduleService, + CancellationToken ct ) => { + IReadOnlyList schedules = await scheduleService.GetAllAsync( ct ); + List dtos = [.. schedules.Select( ScheduleMapper.ToDto )]; + return Results.Ok( dtos ); + } ) + .WithName( "GetSchedules" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapGet( "/api/schedules/{id}", async ( + Guid id, + ScheduleService scheduleService, + CancellationToken ct ) => { + Data.Calendar.Models.Schedule? schedule = await scheduleService.GetByIdAsync( id, ct ); + return schedule is null + ? Results.NotFound( ) + : Results.Ok( ScheduleMapper.ToDto( schedule ) ); + } ) + .WithName( "GetSchedule" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapPost( "/api/schedules", async ( + ScheduleCreateRequest request, + ScheduleService scheduleService, + CancellationToken ct ) => { + try { + Data.Calendar.Models.Schedule schedule = ScheduleMapper.ToSchedule( request ); + Data.Calendar.Models.Schedule created = await scheduleService.CreateAsync( schedule, ct ); + ScheduleDto dto = ScheduleMapper.ToDto( created ); + return Results.Created( $"/api/schedules/{dto.Id}", dto ); + } catch (System.ComponentModel.DataAnnotations.ValidationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } catch (TimeZoneNotFoundException ex) { + return Results.BadRequest( new { message = $"Invalid timezone: {ex.Message}" } ); + } + } ) + .WithName( "CreateSchedule" ) + .RequireAuthorization( Policies.CanCreate ); + + _ = app.MapPut( "/api/schedules/{id}", async ( + Guid id, + ScheduleUpdateRequest request, + ScheduleService scheduleService, + ScheduleInvalidationDispatcher invalidationDispatcher, + CancellationToken ct ) => { + try { + Data.Calendar.Models.Schedule schedule = ScheduleMapper.ToSchedule( id, request ); + Data.Calendar.Models.Schedule updated = await scheduleService.UpdateAsync( schedule, ct ); + + // Push invalidation to affected agents (fire-and-forget) + _ = Task.Run( async ( ) => { + try { await invalidationDispatcher.InvalidateAsync( id, CancellationToken.None ); } catch (Exception ex) { app.Logger.LogError( ex, "Schedule invalidation failed for {ScheduleId}.", id ); } + }, CancellationToken.None ); + + return Results.Ok( ScheduleMapper.ToDto( updated ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (System.ComponentModel.DataAnnotations.ValidationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } catch (TimeZoneNotFoundException ex) { + return Results.BadRequest( new { message = $"Invalid timezone: {ex.Message}" } ); + } + } ) + .WithName( "UpdateSchedule" ) + .RequireAuthorization( Policies.CanUpdate ); + + _ = app.MapDelete( "/api/schedules/{id}", async ( + Guid id, + ScheduleService scheduleService, + ScheduleInvalidationDispatcher invalidationDispatcher, + CancellationToken ct ) => { + try { + // Push invalidation BEFORE deleting so we can still find affected tasks + await invalidationDispatcher.InvalidateAsync( id, ct ); + + await scheduleService.DeleteAsync( id, ct ); + return Results.NoContent( ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "DeleteSchedule" ) + .RequireAuthorization( Policies.CanDelete ); + + _ = app.MapGet( "/api/schedules/{id}/occurrences", async ( + Guid id, + DateTime windowEnd, + ScheduleService scheduleService, + CancellationToken ct ) => { + try { + ScheduleOccurrenceResult result = await scheduleService.PreviewOccurrencesAsync( id, windowEnd, ct ); + IReadOnlyList? suppressed = result.Suppressed.Count > 0 + ? result.Suppressed.Select( s => new SuppressedOccurrenceDto( s.UtcTime, s.HolidayName, s.Reason ) ).ToList( ) + : null; + + // Load schedule to get calendar info for the response + Data.Calendar.Models.Schedule? schedule = await scheduleService.GetByIdAsync( id, ct ); + string? calendarName = schedule?.HolidayCalendar?.Name; + string? calendarMode = schedule?.HolidayCalendarMode?.ToString( ); + + OccurrencePreviewResponse response = new( + id, windowEnd, result.Occurrences, suppressed, calendarName, calendarMode ); + return Results.Ok( response ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "PreviewOccurrences" ) + .RequireAuthorization( Policies.CanRead ); + + return app; + } +} diff --git a/src/Werkr.Api/Endpoints/SettingsEndpoints.cs b/src/Werkr.Api/Endpoints/SettingsEndpoints.cs new file mode 100644 index 0000000..c0652cd --- /dev/null +++ b/src/Werkr.Api/Endpoints/SettingsEndpoints.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using Serilog; +using Werkr.Common.Auth; +using Werkr.Common.Models; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Api.Endpoints; + +/// Maps settings-related REST endpoints. +internal static class SettingsEndpoints { + /// Maps the notify-URL-change endpoint. + public static WebApplication MapSettingsEndpoints( this WebApplication app ) { + _ = app.MapPost( "/api/settings/notify-url-change", async ( + NotifyUrlChangeRequest request, + WerkrDbContext dbContext, + AgentConnectionManager connectionManager, + CancellationToken ct ) => { + if (string.IsNullOrWhiteSpace( request.NewServerUrl )) { + return Results.BadRequest( new { message = "NewServerUrl is required." } ); + } + + if (!Uri.TryCreate( request.NewServerUrl, UriKind.Absolute, out Uri? parsedUri ) + || (parsedUri.Scheme != "https" && parsedUri.Scheme != "http")) { + return Results.BadRequest( new { message = "NewServerUrl must be a valid HTTP or HTTPS URL." } ); + } + + List agents = await dbContext.RegisteredConnections + .AsNoTracking( ) + .Where( c => c.IsServer && c.Status == ConnectionStatus.Connected ) + .ToListAsync( ct ); + + int notified = 0; + List failedAgents = []; + + Werkr.Common.Protos.NotifyServerUrlChangedRequest grpcRequest = new( ) { + NewServerUrl = request.NewServerUrl + }; + + foreach (RegisteredConnection agent in agents) { + try { + (Grpc.Net.Client.GrpcChannel channel, RegisteredConnection conn) = + await connectionManager.GetChannelAsync( agent.Id, ct ); + + string keyId = conn.ActiveKeyId ?? conn.Id.ToString( ); + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( + grpcRequest, conn.SharedKey, keyId ); + + Grpc.Core.CallOptions callOptions = AgentConnectionManager.CreateCallOptions( + conn, + timeout: TimeSpan.FromSeconds( 15 ), + cancellationToken: ct ); + + ConnectionManagement.ConnectionManagementClient client = new( channel ); + EncryptedEnvelope responseEnvelope = + await client.NotifyServerUrlChangedAsync( envelope, callOptions ); + + NotifyServerUrlChangedResponse response = PayloadEncryptor.DecryptFromEnvelope( + responseEnvelope, conn.SharedKey ); + + if (response.Acknowledged) { + notified++; + } else { + failedAgents.Add( agent.ConnectionName ); + } + } catch (Exception ex) { + Log.Warning( ex, + "Failed to notify agent {AgentId} ({Name}) of URL change.", + agent.Id, agent.ConnectionName ); + failedAgents.Add( agent.ConnectionName ); + } + } + + return Results.Ok( new NotifyUrlChangeResponse( notified, failedAgents.Count, failedAgents ) ); + } ) + .WithName( "NotifyUrlChange" ) + .RequireAuthorization( Policies.IsAdmin ); + + return app; + } +} diff --git a/src/Werkr.Api/Endpoints/ShellEndpoints.cs b/src/Werkr.Api/Endpoints/ShellEndpoints.cs new file mode 100644 index 0000000..0874311 --- /dev/null +++ b/src/Werkr.Api/Endpoints/ShellEndpoints.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using Werkr.Common.Auth; +using Werkr.Common.Models; +using Werkr.Core.Communication; + +namespace Werkr.Api.Endpoints; + +/// Maps the SSE streaming shell-execution endpoint. +internal static class ShellEndpoints { + private static readonly JsonSerializerOptions s_jsonOptions = new( ) { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// Maps POST /api/agents/{agentId}/shell/stream — streams operator + /// output as Server-Sent Events so the Blazor UI can render lines in real time. + /// + public static WebApplication MapShellEndpoints( this WebApplication app ) { + _ = app.MapPost( "/api/agents/{agentId}/shell/stream", async ( + Guid agentId, + ExecuteCommandRequest request, + CommandDispatcher commandDispatcher, + HttpContext httpContext, + CancellationToken ct ) => { + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource( ct ); + if (request.TimeoutMinutes > 0) { + cts.CancelAfter( TimeSpan.FromMinutes( request.TimeoutMinutes ) ); + } + + httpContext.Response.ContentType = "text/event-stream"; + httpContext.Response.Headers.CacheControl = "no-cache"; + httpContext.Response.Headers.Connection = "keep-alive"; + httpContext.Response.Headers["X-Accel-Buffering"] = "no"; // disable nginx buffering + + OperatorType operatorType = Enum.Parse( request.OperatorType, ignoreCase: true ); + + try { + await foreach (OperatorOutput output in commandDispatcher.ExecuteCommandAsync( + agentId, operatorType, request.Command, cts.Token )) { + OperatorOutputLine line = new( output.LogLevel, output.Message, output.Timestamp ); + string json = JsonSerializer.Serialize( line, s_jsonOptions ); + + await httpContext.Response.WriteAsync( $"data: {json}\n\n", cts.Token ); + await httpContext.Response.Body.FlushAsync( cts.Token ); + } + + // Signal completion + await httpContext.Response.WriteAsync( "event: done\ndata: {}\n\n", cts.Token ); + await httpContext.Response.Body.FlushAsync( cts.Token ); + } catch (OperationCanceledException) { + // Client disconnected or timeout — write nothing further + } catch (CommandDispatcherException ex) { + string errorJson = JsonSerializer.Serialize( + new { message = ex.UserMessage }, s_jsonOptions ); + await httpContext.Response.WriteAsync( $"event: error\ndata: {errorJson}\n\n", CancellationToken.None ); + await httpContext.Response.Body.FlushAsync( CancellationToken.None ); + } + } ) + .WithName( "StreamShellExecute" ) + .RequireAuthorization( Policies.CanExecute ); + + return app; + } +} diff --git a/src/Werkr.Api/Endpoints/StatusEndpoints.cs b/src/Werkr.Api/Endpoints/StatusEndpoints.cs new file mode 100644 index 0000000..2de5a88 --- /dev/null +++ b/src/Werkr.Api/Endpoints/StatusEndpoints.cs @@ -0,0 +1,17 @@ +namespace Werkr.Api.Endpoints; + +/// Maps the root and status endpoints. +internal static class StatusEndpoints { + /// Maps GET / and GET /api/status. + public static WebApplication MapStatusEndpoints( this WebApplication app ) { + _ = app.MapGet( "/", ( ) => "Werkr API Service is running." ); + + _ = app.MapGet( "/api/status", ( ) => { + return Results.Ok( new { status = "ok" } ); + } ) + .WithName( "GetStatus" ) + .AllowAnonymous( ); + + return app; + } +} diff --git a/src/Werkr.Api/Endpoints/TaskEndpoints.cs b/src/Werkr.Api/Endpoints/TaskEndpoints.cs new file mode 100644 index 0000000..4311492 --- /dev/null +++ b/src/Werkr.Api/Endpoints/TaskEndpoints.cs @@ -0,0 +1,125 @@ +using Werkr.Api.Models; +using Werkr.Common.Auth; +using Werkr.Common.Models; +using Werkr.Core.Communication; +using Werkr.Core.Tasks; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Api.Endpoints; + +/// Maps all task-related REST endpoints. +internal static class TaskEndpoints { + /// Maps task CRUD, enabled-toggle, and run endpoints. + public static WebApplication MapTaskEndpoints( this WebApplication app ) { + _ = app.MapGet( "/api/tasks", async ( + long? workflowId, + TaskService taskService, + CancellationToken ct ) => { + IReadOnlyList tasks = await taskService.GetAllAsync( workflowId, ct ); + List dtos = [.. tasks.Select( TaskMapper.ToDto )]; + return Results.Ok( dtos ); + } ) + .WithName( "GetTasks" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapGet( "/api/tasks/{id}", async ( + long id, + TaskService taskService, + CancellationToken ct ) => { + WerkrTask? task = await taskService.GetByIdAsync( id, ct ); + return task is null ? Results.NotFound( ) : Results.Ok( TaskMapper.ToDto( task ) ); + } ) + .WithName( "GetTask" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapPost( "/api/tasks", async ( + TaskCreateRequest request, + TaskService taskService, + CancellationToken ct ) => { + try { + WerkrTask entity = TaskMapper.ToEntity( request ); + WerkrTask created = await taskService.CreateAsync( entity, ct ); + TaskDto dto = TaskMapper.ToDto( created ); + return Results.Created( $"/api/tasks/{dto.Id}", dto ); + } catch (System.ComponentModel.DataAnnotations.ValidationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } catch (Exception ex) when (ex is FormatException or ArgumentException) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "CreateTask" ) + .RequireAuthorization( Policies.CanCreate ); + + _ = app.MapPut( "/api/tasks/{id}", async ( + long id, + TaskUpdateRequest request, + TaskService taskService, + CancellationToken ct ) => { + try { + WerkrTask entity = TaskMapper.ToEntity( id, request ); + WerkrTask updated = await taskService.UpdateAsync( entity, ct ); + return Results.Ok( TaskMapper.ToDto( updated ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (System.ComponentModel.DataAnnotations.ValidationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } catch (Exception ex) when (ex is FormatException or ArgumentException) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "UpdateTask" ) + .RequireAuthorization( Policies.CanUpdate ); + + _ = app.MapDelete( "/api/tasks/{id}", async ( + long id, + TaskService taskService, + CancellationToken ct ) => { + try { + await taskService.DeleteAsync( id, ct ); + return Results.NoContent( ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "DeleteTask" ) + .RequireAuthorization( Policies.CanDelete ); + + _ = app.MapPut( "/api/tasks/{id}/enabled", async ( + long id, + TaskSetEnabledRequest request, + TaskService taskService, + CancellationToken ct ) => { + try { + await taskService.SetEnabledAsync( id, request.Enabled, ct ); + return Results.NoContent( ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "SetTaskEnabled" ) + .RequireAuthorization( Policies.CanUpdate ); + + _ = app.MapPost( "/api/tasks/{id}/run", async ( + long id, + TaskRunRequest? request, + JobExecutionService jobExecutionService, + CancellationToken ct ) => { + try { + WerkrJob job = await jobExecutionService.ExecuteAsync( id, ct ); + return Results.Ok( TaskMapper.ToJobDto( job ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( new { message = $"Task with Id={id} was not found." } ); + } catch (InvalidOperationException ex) { + return Results.Conflict( new { message = ex.Message } ); + } catch (CommandDispatcherException ex) { + return Results.Json( + new { message = ex.UserMessage }, + statusCode: 502 ); + } + } ) + .WithName( "RunTask" ) + .RequireAuthorization( Policies.CanExecute ); + + return app; + } +} diff --git a/src/Werkr.Api/Endpoints/WorkflowEndpoints.cs b/src/Werkr.Api/Endpoints/WorkflowEndpoints.cs new file mode 100644 index 0000000..28874d2 --- /dev/null +++ b/src/Werkr.Api/Endpoints/WorkflowEndpoints.cs @@ -0,0 +1,307 @@ +using System.ComponentModel.DataAnnotations; + +using Werkr.Api.Models; +using Werkr.Common.Auth; +using Werkr.Common.Models; +using Werkr.Core.Workflows; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Api.Endpoints; + +/// +/// Extension methods for mapping workflow REST endpoints to the application. +/// +internal static class WorkflowEndpoints { + + /// Maps all workflow-related REST endpoints. + public static WebApplication MapWorkflowEndpoints( this WebApplication app ) { + MapWorkflowCrud( app ); + MapWorkflowSteps( app ); + MapStepDependencies( app ); + MapWorkflowExecution( app ); + return app; + } + + // ── Workflow CRUD ── + + private static void MapWorkflowCrud( WebApplication app ) { + + _ = app.MapGet( "/api/workflows", async ( + WorkflowService workflowService, + CancellationToken ct ) => { + IReadOnlyList workflows = await workflowService.GetAllAsync( ct ); + List dtos = [.. workflows.Select( WorkflowMapper.ToDto )]; + return Results.Ok( dtos ); + } ) + .WithName( "GetWorkflows" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapGet( "/api/workflows/{id}", async ( + long id, + WorkflowService workflowService, + CancellationToken ct ) => { + Workflow? workflow = await workflowService.GetByIdAsync( id, ct ); + return workflow is null + ? Results.NotFound( ) + : Results.Ok( WorkflowMapper.ToDto( workflow ) ); + } ) + .WithName( "GetWorkflow" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapPost( "/api/workflows", async ( + WorkflowCreateRequest request, + WorkflowService workflowService, + CancellationToken ct ) => { + try { + Workflow entity = WorkflowMapper.ToEntity( request ); + Workflow created = await workflowService.CreateAsync( entity, ct ); + WorkflowDto dto = WorkflowMapper.ToDto( created ); + return Results.Created( $"/api/workflows/{dto.Id}", dto ); + } catch (ValidationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "CreateWorkflow" ) + .RequireAuthorization( Policies.CanCreate ); + + _ = app.MapPut( "/api/workflows/{id}", async ( + long id, + WorkflowUpdateRequest request, + WorkflowService workflowService, + CancellationToken ct ) => { + try { + Workflow entity = WorkflowMapper.ToEntity( id, request ); + Workflow updated = await workflowService.UpdateAsync( entity, ct ); + return Results.Ok( WorkflowMapper.ToDto( updated ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (ValidationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "UpdateWorkflow" ) + .RequireAuthorization( Policies.CanUpdate ); + + _ = app.MapDelete( "/api/workflows/{id}", async ( + long id, + WorkflowService workflowService, + CancellationToken ct ) => { + try { + await workflowService.DeleteAsync( id, ct ); + return Results.NoContent( ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "DeleteWorkflow" ) + .RequireAuthorization( Policies.CanDelete ); + + _ = app.MapPatch( "/api/workflows/{id}/enabled", async ( + long id, + WorkflowSetEnabledRequest request, + WorkflowService workflowService, + CancellationToken ct ) => { + Workflow? workflow = await workflowService.GetByIdAsync( id, ct ); + if (workflow is null) { + return Results.NotFound( ); + } + + workflow.Enabled = request.Enabled; + _ = await workflowService.UpdateAsync( workflow, ct ); + return Results.Ok( WorkflowMapper.ToDto( workflow ) ); + } ) + .WithName( "SetWorkflowEnabled" ) + .RequireAuthorization( Policies.CanUpdate ); + } + + // ── Workflow Steps ── + + private static void MapWorkflowSteps( WebApplication app ) { + + _ = app.MapPost( "/api/workflows/{workflowId}/steps", async ( + long workflowId, + WorkflowStepCreateRequest request, + WorkflowService workflowService, + CancellationToken ct ) => { + try { + WorkflowStep step = WorkflowMapper.ToStepEntity( workflowId, request ); + WorkflowStep created = await workflowService.AddStepAsync( workflowId, step, ct ); + WorkflowStepDto dto = WorkflowMapper.ToStepDto( created ); + return Results.Created( $"/api/workflows/{workflowId}/steps/{dto.Id}", dto ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (Exception ex) when (ex is FormatException or ArgumentException) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "AddWorkflowStep" ) + .RequireAuthorization( Policies.CanCreate ); + + _ = app.MapPut( "/api/workflows/{workflowId}/steps/{stepId}", async ( + long workflowId, + long stepId, + WorkflowStepUpdateRequest request, + WorkflowService workflowService, + CancellationToken ct ) => { + try { + WorkflowStep step = new( ) { + Id = stepId, + WorkflowId = workflowId, + Order = request.Order, + ControlStatement = Enum.Parse( + request.ControlStatement, ignoreCase: true ), + ConditionExpression = request.ConditionExpression, + MaxIterations = request.MaxIterations, + AgentConnectionIdOverride = request.AgentConnectionIdOverride, + DependencyMode = Enum.Parse( + request.DependencyMode, ignoreCase: true ), + }; + WorkflowStep updated = await workflowService.UpdateStepAsync( step, ct ); + return Results.Ok( WorkflowMapper.ToStepDto( updated ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (Exception ex) when (ex is FormatException or ArgumentException) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "UpdateWorkflowStep" ) + .RequireAuthorization( Policies.CanUpdate ); + + _ = app.MapDelete( "/api/workflows/{workflowId}/steps/{stepId}", async ( + long workflowId, + long stepId, + WorkflowService workflowService, + CancellationToken ct ) => { + try { + await workflowService.RemoveStepAsync( stepId, ct ); + return Results.NoContent( ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "RemoveWorkflowStep" ) + .RequireAuthorization( Policies.CanDelete ); + } + + // ── Step Dependencies ── + + private static void MapStepDependencies( WebApplication app ) { + + _ = app.MapPost( "/api/workflows/{workflowId}/steps/{stepId}/dependencies", async ( + long workflowId, + long stepId, + StepDependencyRequest request, + WorkflowService workflowService, + CancellationToken ct ) => { + try { + await workflowService.AddStepDependencyAsync( + stepId, request.DependsOnStepId, ct ); + StepDependencyDto depDto = new( StepId: stepId, DependsOnStepId: request.DependsOnStepId ); + return Results.Created( + $"/api/workflows/{workflowId}/steps/{stepId}/dependencies/{request.DependsOnStepId}", + depDto ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (InvalidOperationException ex) { + return Results.BadRequest( new { message = ex.Message } ); + } + } ) + .WithName( "AddStepDependency" ) + .RequireAuthorization( Policies.CanCreate ); + + _ = app.MapDelete( "/api/workflows/{workflowId}/steps/{stepId}/dependencies/{dependsOnStepId}", async ( + long workflowId, + long stepId, + long dependsOnStepId, + WorkflowService workflowService, + CancellationToken ct ) => { + try { + await workflowService.RemoveStepDependencyAsync( stepId, dependsOnStepId, ct ); + return Results.NoContent( ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } + } ) + .WithName( "RemoveStepDependency" ) + .RequireAuthorization( Policies.CanDelete ); + } + + // ── Workflow Execution & Runs ── + + private static void MapWorkflowExecution( WebApplication app ) { + + _ = app.MapPost( "/api/workflows/{id}/validate", async ( + long id, + WorkflowService workflowService, + CancellationToken ct ) => { + try { + _ = await workflowService.ValidateDagAsync( id, ct ); + return Results.Ok( new DagValidationResult( + IsValid: true, Errors: [] ) ); + } catch (KeyNotFoundException) { + return Results.NotFound( ); + } catch (InvalidOperationException ex) { + return Results.Ok( new DagValidationResult( + IsValid: false, Errors: [ex.Message] ) ); + } + } ) + .WithName( "ValidateWorkflow" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapPost( "/api/workflows/{id}/run", async ( + long id, + WorkflowService workflowService, + WorkflowExecutor workflowExecutor, + CancellationToken ct ) => { + Workflow? workflow = await workflowService.GetByIdAsync( id, ct ); + if (workflow is null) { + return Results.NotFound( ); + } + + if (!workflow.Enabled) { + return Results.BadRequest( new { message = "Workflow is disabled." } ); + } + + WorkflowRun run = await workflowExecutor.ExecuteAsync( workflow, ct ); + return Results.Ok( WorkflowMapper.ToRunDto( run ) ); + } ) + .WithName( "RunWorkflow" ) + .RequireAuthorization( Policies.CanExecute ); + + _ = app.MapGet( "/api/workflows/{id}/runs", async ( + long id, + int? limit, + WorkflowExecutor workflowExecutor, + CancellationToken ct ) => { + IReadOnlyList runs = await workflowExecutor.GetRunsAsync( + id, limit ?? 50, ct ); + List dtos = [.. runs.Select( WorkflowMapper.ToRunDto )]; + return Results.Ok( dtos ); + } ) + .WithName( "GetWorkflowRuns" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapGet( "/api/workflows/runs/{runId}", async ( + Guid runId, + WorkflowExecutor workflowExecutor, + CancellationToken ct ) => { + WorkflowRun? run = await workflowExecutor.GetRunAsync( runId, ct ); + return run is null + ? Results.NotFound( ) + : Results.Ok( WorkflowMapper.ToRunDetailDto( run ) ); + } ) + .WithName( "GetWorkflowRun" ) + .RequireAuthorization( Policies.CanRead ); + + _ = app.MapGet( "/api/workflows/runs/{runId}/stream", ( + Guid runId, + WorkflowRunTracker tracker ) => { + IAsyncEnumerable? updates = tracker.GetUpdates( runId ); + return updates is null + ? Results.NotFound( ) + : Results.Ok( updates ); + } ) + .WithName( "StreamWorkflowRunUpdates" ) + .RequireAuthorization( Policies.CanRead ); + } +} diff --git a/src/Werkr.Api/Interceptors/AgentBearerTokenInterceptor.cs b/src/Werkr.Api/Interceptors/AgentBearerTokenInterceptor.cs new file mode 100644 index 0000000..fff040f --- /dev/null +++ b/src/Werkr.Api/Interceptors/AgentBearerTokenInterceptor.cs @@ -0,0 +1,130 @@ +using System.Security.Cryptography; +using System.Text; + +using Grpc.Core; +using Grpc.Core.Interceptors; + +using Microsoft.EntityFrameworkCore; + +using Werkr.Common.Models; +using Werkr.Core.Cryptography; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Api.Interceptors; + +/// +/// gRPC server interceptor that validates Agent bearer tokens on incoming calls. +/// Looks up the by the x-werkr-connection-id +/// header and verifies the bearer token against the stored . +/// +/// If no x-werkr-connection-id header is present the call is assumed to be a +/// user/service call authenticated via JWT and the interceptor is a no-op. +/// +/// +public class AgentBearerTokenInterceptor( + IServiceScopeFactory scopeFactory, + ILogger logger +) : Interceptor { + + /// + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation ) { + await ValidateBearerTokenAsync( context ); + return await continuation( request, context ); + } + + /// + public override async Task ServerStreamingServerHandler( + TRequest request, + IServerStreamWriter responseStream, + ServerCallContext context, + ServerStreamingServerMethod continuation ) { + await ValidateBearerTokenAsync( context ); + await continuation( request, responseStream, context ); + } + + /// + public override async Task ClientStreamingServerHandler( + IAsyncStreamReader requestStream, + ServerCallContext context, + ClientStreamingServerMethod continuation ) { + await ValidateBearerTokenAsync( context ); + return await continuation( requestStream, context ); + } + + /// + public override async Task DuplexStreamingServerHandler( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context, + DuplexStreamingServerMethod continuation ) { + await ValidateBearerTokenAsync( context ); + await continuation( requestStream, responseStream, context ); + } + + private async Task ValidateBearerTokenAsync( ServerCallContext context ) { + // If no connection-id header is present, this is a user/service call — let JWT handle it. + string? connectionIdStr = context.RequestHeaders.GetValue( "x-werkr-connection-id" ); + if (string.IsNullOrEmpty( connectionIdStr )) { + return; + } + + if (!Guid.TryParse( connectionIdStr, out Guid connectionId )) { + throw new RpcException( new Status( StatusCode.Unauthenticated, + "Invalid x-werkr-connection-id header." ) ); + } + + // Extract bearer token + string? authHeader = context.RequestHeaders.GetValue( "authorization" ); + if (string.IsNullOrEmpty( authHeader ) || + !authHeader.StartsWith( "Bearer ", StringComparison.OrdinalIgnoreCase )) { + throw new RpcException( new Status( StatusCode.Unauthenticated, + "Missing or malformed authorization header." ) ); + } + + string token = authHeader["Bearer ".Length..]; + + // Resolve the Server-side connection record from the app database + using IServiceScope scope = scopeFactory.CreateScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + RegisteredConnection? connection = await dbContext.RegisteredConnections + .FirstOrDefaultAsync( c => c.Id == connectionId && c.IsServer ); + + if (connection is null || connection.Status == ConnectionStatus.Revoked) { + logger.LogWarning( "Agent gRPC call rejected: connection {ConnectionId} not found or revoked.", + connectionId ); + throw new RpcException( new Status( StatusCode.Unauthenticated, + "Connection not found or revoked." ) ); + } + + // Constant-time token comparison (SHA-512 hash then fixed-time equals) + string tokenHash = EncryptionProvider.HashSHA512String( token ); + byte[] receivedBytes = Encoding.UTF8.GetBytes( tokenHash ); + byte[] storedBytes = Encoding.UTF8.GetBytes( connection.InboundApiKeyHash ); + + if (!CryptographicOperations.FixedTimeEquals( receivedBytes, storedBytes )) { + logger.LogWarning( "Agent gRPC call rejected: invalid bearer token for connection {ConnectionId}.", + connectionId ); + throw new RpcException( new Status( StatusCode.Unauthenticated, + "Invalid bearer token." ) ); + } + + // Debounced LastSeen update (only write if null or older than 60 seconds) + if (connection.LastSeen is null || connection.LastSeen < DateTime.UtcNow.AddSeconds( -60 )) { + connection.LastSeen = DateTime.UtcNow; + _ = await dbContext.SaveChangesAsync( ); + } + + // Store resolved connection and optional call ID in UserState for downstream services + context.UserState["Connection"] = connection; + + string? callId = context.RequestHeaders.GetValue( "x-werkr-call-id" ); + if (!string.IsNullOrEmpty( callId )) { + context.UserState["CallId"] = callId; + } + } +} diff --git a/src/Werkr.Api/Models/HolidayCalendarMapper.cs b/src/Werkr.Api/Models/HolidayCalendarMapper.cs new file mode 100644 index 0000000..f5560b8 --- /dev/null +++ b/src/Werkr.Api/Models/HolidayCalendarMapper.cs @@ -0,0 +1,170 @@ +using Werkr.Common.Models.Holidays; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Api.Models; + +/// +/// Bidirectional mapping between Holiday Calendar DTOs and domain entities. +/// +internal static class HolidayCalendarMapper { + + // ── Entity → DTO ─────────────────────────────────────────────────────────── + + /// Converts a with loaded collections to a full DTO. + public static HolidayCalendarDto ToDto( HolidayCalendar calendar ) => + new( + Id: calendar.Id, + Name: calendar.Name, + Description: calendar.Description, + IsSystemCalendar: calendar.IsSystemCalendar, + CreatedUtc: calendar.CreatedUtc, + UpdatedUtc: calendar.UpdatedUtc, + Rules: calendar.Rules?.Select( ToDto ).ToList( ) ?? [], + Dates: calendar.Dates?.Select( ToDto ).ToList( ) ?? [] ); + + /// Converts a to a summary DTO with counts. + public static HolidayCalendarSummaryDto ToSummaryDto( HolidayCalendar calendar, int scheduleCount ) => + new( + Id: calendar.Id, + Name: calendar.Name, + Description: calendar.Description, + IsSystemCalendar: calendar.IsSystemCalendar, + RuleCount: calendar.Rules?.Count ?? 0, + AttachedScheduleCount: scheduleCount ); + + /// Converts a to a DTO. + public static HolidayRuleDto ToDto( HolidayRule rule ) => + new( + Id: rule.Id, + HolidayCalendarId: rule.HolidayCalendarId, + Name: rule.Name, + RuleType: rule.RuleType.ToString( ), + Month: rule.Month, + Day: rule.Day, + DayOfWeek: rule.DayOfWeek?.ToString( ), + WeekNumber: rule.WeekNumber, + WindowStart: rule.WindowStart?.ToString( "HH:mm:ss" ), + WindowEnd: rule.WindowEnd?.ToString( "HH:mm:ss" ), + WindowTimeZoneId: rule.WindowTimeZoneId, + ObservanceRule: rule.ObservanceRule.ToString( ), + YearStart: rule.YearStart, + YearEnd: rule.YearEnd ); + + /// Converts a to a DTO. + public static HolidayDateDto ToDto( HolidayDate date ) => + new( + Id: date.Id, + HolidayCalendarId: date.HolidayCalendarId, + Date: date.Date.ToString( "yyyy-MM-dd" ), + Name: date.Name, + Year: date.Year, + WindowStart: date.WindowStart?.ToString( "HH:mm:ss" ), + WindowEnd: date.WindowEnd?.ToString( "HH:mm:ss" ), + WindowTimeZoneId: date.WindowTimeZoneId, + IsManual: date.IsManual, + GeneratedByRuleId: date.HolidayRuleId ); + + /// Converts a to a DTO. + public static ScheduleAuditLogDto ToDto( ScheduleAuditLog log ) => + new( + Id: log.Id, + ScheduleId: log.ScheduleId, + OccurrenceUtcTime: log.OccurrenceUtcTime, + CalendarName: log.CalendarName, + HolidayName: log.HolidayName, + Mode: log.Mode.ToString( ), + CreatedUtc: log.CreatedUtc ); + + // ── DTO → Entity ─────────────────────────────────────────────────────────── + + /// Creates a from a create request. + public static HolidayCalendar ToEntity( HolidayCalendarCreateRequest request ) => + new( ) { + Name = request.Name, + Description = request.Description, + }; + + /// Creates a from a create request. + public static HolidayRule ToEntity( HolidayRuleCreateRequest request ) => + new( ) { + Name = request.Name, + RuleType = Enum.Parse( request.RuleType, ignoreCase: true ), + Month = request.Month, + Day = request.Day, + DayOfWeek = request.DayOfWeek is not null + ? Enum.Parse( request.DayOfWeek, ignoreCase: true ) + : null, + WeekNumber = request.WeekNumber, + WindowStart = request.WindowStart is not null ? TimeOnly.Parse( request.WindowStart ) : null, + WindowEnd = request.WindowEnd is not null ? TimeOnly.Parse( request.WindowEnd ) : null, + WindowTimeZoneId = request.WindowTimeZoneId, + ObservanceRule = Enum.Parse( request.ObservanceRule, ignoreCase: true ), + YearStart = request.YearStart, + YearEnd = request.YearEnd, + }; + + /// Creates a from an update request. + public static HolidayRule ToEntity( HolidayRuleUpdateRequest request ) => + new( ) { + Name = request.Name, + RuleType = Enum.Parse( request.RuleType, ignoreCase: true ), + Month = request.Month, + Day = request.Day, + DayOfWeek = request.DayOfWeek is not null + ? Enum.Parse( request.DayOfWeek, ignoreCase: true ) + : null, + WeekNumber = request.WeekNumber, + WindowStart = request.WindowStart is not null ? TimeOnly.Parse( request.WindowStart ) : null, + WindowEnd = request.WindowEnd is not null ? TimeOnly.Parse( request.WindowEnd ) : null, + WindowTimeZoneId = request.WindowTimeZoneId, + ObservanceRule = Enum.Parse( request.ObservanceRule, ignoreCase: true ), + YearStart = request.YearStart, + YearEnd = request.YearEnd, + }; + + /// Creates a from a rule preview request. + public static HolidayRule ToEntity( RulePreviewRequest request ) => + new( ) { + Name = request.Name, + RuleType = Enum.Parse( request.RuleType, ignoreCase: true ), + Month = request.Month, + Day = request.Day, + DayOfWeek = request.DayOfWeek is not null + ? Enum.Parse( request.DayOfWeek, ignoreCase: true ) + : null, + WeekNumber = request.WeekNumber, + WindowStart = request.WindowStart is not null ? TimeOnly.Parse( request.WindowStart ) : null, + WindowEnd = request.WindowEnd is not null ? TimeOnly.Parse( request.WindowEnd ) : null, + WindowTimeZoneId = request.WindowTimeZoneId, + ObservanceRule = Enum.Parse( request.ObservanceRule, ignoreCase: true ), + YearStart = request.YearStart, + YearEnd = request.YearEnd, + }; + + /// Creates a from a create request. + public static HolidayDate ToEntity( HolidayDateCreateRequest request ) => + new( ) { + Date = DateOnly.Parse( request.Date ), + Name = request.Name, + Year = DateOnly.Parse( request.Date ).Year, + WindowStart = request.WindowStart is not null ? TimeOnly.Parse( request.WindowStart ) : null, + WindowEnd = request.WindowEnd is not null ? TimeOnly.Parse( request.WindowEnd ) : null, + WindowTimeZoneId = request.WindowTimeZoneId, + }; + + /// Creates a from a create request. + public static ScheduleAuditLog ToAuditLog( + ScheduleAuditLogCreateRequest request, + Guid scheduleId, + string calendarName, + HolidayCalendarMode mode ) => + new( ) { + ScheduleId = scheduleId, + OccurrenceUtcTime = request.OccurrenceUtcTime, + CalendarName = calendarName, + HolidayName = request.HolidayName, + Mode = mode, + CreatedUtc = DateTime.UtcNow, + }; +} diff --git a/src/Werkr.Api/Models/ScheduleMapper.cs b/src/Werkr.Api/Models/ScheduleMapper.cs new file mode 100644 index 0000000..078d9d3 --- /dev/null +++ b/src/Werkr.Api/Models/ScheduleMapper.cs @@ -0,0 +1,112 @@ +using Werkr.Common.Models; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Models; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Api.Models; + +/// +/// Bidirectional mapping between Schedule DTOs and domain entities. +/// +internal static class ScheduleMapper { + /// Creates a composite from a create request. + public static Schedule ToSchedule( ScheduleCreateRequest request ) => + new( ) { + DbSchedule = new DbSchedule { + Name = request.Name, + StopTaskAfterMinutes = request.StopTaskAfterMinutes, + }, + StartDateTime = ToStartDateTimeInfo( request.StartDateTime ), + Expiration = request.Expiration is not null ? ToExpirationDateTimeInfo( request.Expiration ) : null, + DailyRecurrence = request.DailyRecurrence is not null ? ToDailyRecurrence( request.DailyRecurrence ) : null, + WeeklyRecurrence = request.WeeklyRecurrence is not null ? ToWeeklyRecurrence( request.WeeklyRecurrence ) : null, + MonthlyRecurrence = request.MonthlyRecurrence is not null ? ToMonthlyRecurrence( request.MonthlyRecurrence ) : null, + RepeatOptions = request.RepeatOptions is not null ? ToRepeatOptions( request.RepeatOptions ) : null, + }; + + /// Creates a composite from an update request, preserving the given ID. + public static Schedule ToSchedule( Guid id, ScheduleUpdateRequest request ) => + new( ) { + DbSchedule = new DbSchedule { + Id = id, + Name = request.Name, + StopTaskAfterMinutes = request.StopTaskAfterMinutes, + }, + StartDateTime = ToStartDateTimeInfo( request.StartDateTime ), + Expiration = request.Expiration is not null ? ToExpirationDateTimeInfo( request.Expiration ) : null, + DailyRecurrence = request.DailyRecurrence is not null ? ToDailyRecurrence( request.DailyRecurrence ) : null, + WeeklyRecurrence = request.WeeklyRecurrence is not null ? ToWeeklyRecurrence( request.WeeklyRecurrence ) : null, + MonthlyRecurrence = request.MonthlyRecurrence is not null ? ToMonthlyRecurrence( request.MonthlyRecurrence ) : null, + RepeatOptions = request.RepeatOptions is not null ? ToRepeatOptions( request.RepeatOptions ) : null, + }; + + /// Converts a composite to a response DTO. + public static ScheduleDto ToDto( Schedule schedule ) => + new( + Id: schedule.DbSchedule.Id, + Name: schedule.DbSchedule.Name, + StopTaskAfterMinutes: schedule.DbSchedule.StopTaskAfterMinutes, + StartDateTime: schedule.StartDateTime is not null ? ToDto( schedule.StartDateTime ) : null, + Expiration: schedule.Expiration is not null ? ToDto( schedule.Expiration ) : null, + DailyRecurrence: schedule.DailyRecurrence is not null ? ToDto( schedule.DailyRecurrence ) : null, + WeeklyRecurrence: schedule.WeeklyRecurrence is not null ? ToDto( schedule.WeeklyRecurrence ) : null, + MonthlyRecurrence: schedule.MonthlyRecurrence is not null ? ToDto( schedule.MonthlyRecurrence ) : null, + RepeatOptions: schedule.RepeatOptions is not null ? ToDto( schedule.RepeatOptions ) : null + ); + + // -- Entity → DTO -- + + private static StartDateTimeDto ToDto( StartDateTimeInfo info ) => + new( info.Date, info.Time, info.TimeZone.Id ); + + private static ExpirationDateTimeDto ToDto( ExpirationDateTimeInfo info ) => + new( info.Date, info.Time, info.TimeZone.Id ); + + private static DailyRecurrenceDto ToDto( DailyRecurrence recurrence ) => + new( recurrence.DayInterval ); + + private static WeeklyRecurrenceDto ToDto( WeeklyRecurrence recurrence ) => + new( recurrence.WeekInterval, (int)recurrence.DaysOfWeek ); + + private static MonthlyRecurrenceDto ToDto( MonthlyRecurrence recurrence ) => + new( recurrence.DayNumbers, (int)recurrence.MonthsOfYear, (int?)recurrence.WeekNumber, (int?)recurrence.DaysOfWeek ); + + private static RepeatOptionsDto ToDto( ScheduleRepeatOptions options ) => + new( options.RepeatIntervalMinutes, options.RepeatDurationMinutes ); + + // -- DTO → Entity -- + + private static StartDateTimeInfo ToStartDateTimeInfo( StartDateTimeDto dto ) => + new( ) { + Date = dto.Date, + Time = dto.Time, + TimeZone = TimeZoneInfo.FindSystemTimeZoneById( dto.TimeZoneId ), + }; + + private static ExpirationDateTimeInfo ToExpirationDateTimeInfo( ExpirationDateTimeDto dto ) => + new( ) { + Date = dto.Date, + Time = dto.Time, + TimeZone = TimeZoneInfo.FindSystemTimeZoneById( dto.TimeZoneId ), + }; + + private static DailyRecurrence ToDailyRecurrence( DailyRecurrenceDto dto ) => + new( ) { DayInterval = dto.DayInterval }; + + private static WeeklyRecurrence ToWeeklyRecurrence( WeeklyRecurrenceDto dto ) => + new( ) { WeekInterval = dto.WeekInterval, DaysOfWeek = (DaysOfWeek)dto.DaysOfWeek }; + + private static MonthlyRecurrence ToMonthlyRecurrence( MonthlyRecurrenceDto dto ) => + new( ) { + DayNumbers = dto.DayNumbers, + MonthsOfYear = (MonthsOfYear)dto.MonthsOfYear, + WeekNumber = (WeekNumberWithinMonth?)dto.WeekNumber, + DaysOfWeek = (DaysOfWeek?)dto.DaysOfWeek, + }; + + private static ScheduleRepeatOptions ToRepeatOptions( RepeatOptionsDto dto ) => + new( ) { + RepeatIntervalMinutes = dto.RepeatIntervalMinutes, + RepeatDurationMinutes = dto.RepeatDurationMinutes, + }; +} diff --git a/src/Werkr.Api/Models/TaskMapper.cs b/src/Werkr.Api/Models/TaskMapper.cs new file mode 100644 index 0000000..9cb7baa --- /dev/null +++ b/src/Werkr.Api/Models/TaskMapper.cs @@ -0,0 +1,177 @@ +using System.Text.Json; + +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Core.Tasks; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Api.Models; + +/// +/// Bidirectional mapping between Task/Job DTOs and domain entities. +/// +internal static class TaskMapper { + + private static readonly Dictionary s_actionParameterTypes = + new( StringComparer.OrdinalIgnoreCase ) { + ["CopyFile"] = typeof( CopyFileParameters ), + ["MoveFile"] = typeof( MoveFileParameters ), + ["RenameFile"] = typeof( RenameFileParameters ), + ["DeleteFile"] = typeof( DeleteFileParameters ), + ["CreateFile"] = typeof( CreateFileParameters ), + ["CreateDirectory"] = typeof( CreateDirectoryParameters ), + ["TestExists"] = typeof( TestExistsParameters ), + ["ClearContent"] = typeof( ClearContentParameters ), + ["WriteContent"] = typeof( WriteContentParameters ), + ["StartProcess"] = typeof( StartProcessParameters ), + ["StopProcess"] = typeof( StopProcessParameters ), + }; + + private static readonly JsonSerializerOptions s_jsonOptions = new( ) { + PropertyNameCaseInsensitive = true, + }; + + /// Maps a to a entity. + public static WerkrTask ToEntity( TaskCreateRequest request ) { + ValidateActionFields( request.ActionType, request.ActionSubType, request.ActionParameters ); + + return new( ) { + Name = request.Name, + Description = request.Description ?? string.Empty, + ActionType = Enum.Parse( request.ActionType, ignoreCase: true ), + Content = request.Content, + Arguments = request.Arguments, + TargetTags = request.TargetTags, + Enabled = request.Enabled, + TimeoutMinutes = request.TimeoutMinutes, + SuccessCriteria = request.SuccessCriteria, + ScheduleId = request.ScheduleId, + WorkflowId = request.WorkflowId, + ActionSubType = request.ActionSubType, + ActionParameters = request.ActionParameters, + }; + } + + /// Maps a to a entity with a given ID. + public static WerkrTask ToEntity( long id, TaskUpdateRequest request ) { + ValidateActionFields( request.ActionType, request.ActionSubType, request.ActionParameters ); + + return new( ) { + Id = id, + Name = request.Name, + Description = request.Description ?? string.Empty, + ActionType = Enum.Parse( request.ActionType, ignoreCase: true ), + Content = request.Content, + Arguments = request.Arguments, + TargetTags = request.TargetTags, + Enabled = request.Enabled, + TimeoutMinutes = request.TimeoutMinutes, + SuccessCriteria = request.SuccessCriteria, + ScheduleId = request.ScheduleId, + WorkflowId = request.WorkflowId, + ActionSubType = request.ActionSubType, + ActionParameters = request.ActionParameters, + }; + } + + /// Maps a entity to a . + public static TaskDto ToDto( WerkrTask task ) => + new( + Id: task.Id, + Name: task.Name, + Description: task.Description, + ActionType: task.ActionType.ToString( ), + Content: task.Content, + Arguments: task.Arguments, + TargetTags: task.TargetTags, + Enabled: task.Enabled, + TimeoutMinutes: task.TimeoutMinutes, + SyncIntervalMinutes: task.SyncIntervalMinutes, + SuccessCriteria: task.SuccessCriteria, + EffectiveSuccessCriteria: SuccessCriteriaEvaluator.DescribeEffectiveCriteria( + task.ActionType, task.SuccessCriteria ), + ScheduleId: task.ScheduleId, + WorkflowId: task.WorkflowId, + ActionSubType: task.ActionSubType, + ActionParameters: task.ActionParameters ); + + /// Maps a entity to a . + public static JobDto ToJobDto( WerkrJob job ) => + new( + Id: job.Id, + TaskId: job.TaskId, + Success: job.Success, + ExitCode: job.ExitCode, + ErrorCategory: job.ErrorCategory.ToString( ), + RuntimeSeconds: job.RuntimeSeconds, + StartTime: job.StartTime, + EndTime: job.EndTime, + AgentConnectionId: job.AgentConnectionId, + Output: job.Output, + OutputPath: job.OutputPath ); + + /// Maps a entity to a . + public static JobListDto ToJobListDto( WerkrJob job ) => + new( + Id: job.Id, + TaskId: job.TaskId, + Success: job.Success, + RuntimeSeconds: job.RuntimeSeconds, + StartTime: job.StartTime, + ErrorCategory: job.ErrorCategory.ToString( ), + TaskName: job.Task?.Name, + AgentConnectionId: job.AgentConnectionId, + AgentName: job.AgentConnection?.ConnectionName, + EndTime: job.EndTime ); + + private static void ValidateActionFields( + string actionType, + string? actionSubType, + string? actionParameters ) { + + bool isActionTask = string.Equals( + actionType, + TaskActionType.Action.ToString( ), + StringComparison.OrdinalIgnoreCase ); + + if (isActionTask) { + if (string.IsNullOrWhiteSpace( actionSubType )) { + throw new ArgumentException( "ActionSubType is required when ActionType is 'Action'." ); + } + + if (!s_actionParameterTypes.TryGetValue( actionSubType, out Type? parameterType )) { + throw new ArgumentException( $"Unknown ActionSubType '{actionSubType}'." ); + } + + if (string.IsNullOrWhiteSpace( actionParameters )) { + throw new ArgumentException( "ActionParameters is required when ActionType is 'Action'." ); + } + + try { + using JsonDocument parsed = JsonDocument.Parse( actionParameters ); + if (parsed.RootElement.ValueKind != JsonValueKind.Object) { + throw new ArgumentException( "ActionParameters must be a JSON object." ); + } + + object? deserialized = JsonSerializer.Deserialize( + actionParameters, + parameterType, + s_jsonOptions ) ?? throw new ArgumentException( + $"ActionParameters could not be deserialized for action '{actionSubType}'." ); + } catch (JsonException ex) { + throw new ArgumentException( + $"ActionParameters is not valid JSON for action '{actionSubType}': {ex.Message}", ex ); + } + + return; + } + + if (!string.IsNullOrWhiteSpace( actionSubType )) { + throw new ArgumentException( "ActionSubType must be null when ActionType is not 'Action'." ); + } + + if (!string.IsNullOrWhiteSpace( actionParameters )) { + throw new ArgumentException( "ActionParameters must be null when ActionType is not 'Action'." ); + } + } +} diff --git a/src/Werkr.Api/Models/WorkflowMapper.cs b/src/Werkr.Api/Models/WorkflowMapper.cs new file mode 100644 index 0000000..4d1ca15 --- /dev/null +++ b/src/Werkr.Api/Models/WorkflowMapper.cs @@ -0,0 +1,89 @@ +using Werkr.Common.Models; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Api.Models; + +/// +/// Bidirectional mapping between Workflow DTOs and domain entities. +/// +internal static class WorkflowMapper { + + /// Maps a to a entity. + public static Workflow ToEntity( WorkflowCreateRequest request ) => + new( ) { + Name = request.Name, + Description = request.Description ?? string.Empty, + Enabled = request.Enabled, + ScheduleId = request.ScheduleId, + }; + + /// Maps a to a entity with a given ID. + public static Workflow ToEntity( long id, WorkflowUpdateRequest request ) => + new( ) { + Id = id, + Name = request.Name, + Description = request.Description ?? string.Empty, + Enabled = request.Enabled, + ScheduleId = request.ScheduleId, + }; + + /// Maps a entity to a . + public static WorkflowDto ToDto( Workflow workflow ) => + new( + Id: workflow.Id, + Name: workflow.Name, + Description: workflow.Description, + Enabled: workflow.Enabled, + ScheduleId: workflow.ScheduleId, + Steps: [.. workflow.Steps.Select( ToStepDto )] ); + + /// Maps a entity to a . + public static WorkflowStepDto ToStepDto( WorkflowStep step ) => + new( + Id: step.Id, + WorkflowId: step.WorkflowId, + TaskId: step.TaskId, + Order: step.Order, + ControlStatement: step.ControlStatement.ToString( ), + ConditionExpression: step.ConditionExpression, + MaxIterations: step.MaxIterations, + AgentConnectionIdOverride: step.AgentConnectionIdOverride, + DependencyMode: step.DependencyMode.ToString( ), + Dependencies: [.. step.Dependencies.Select( ToDepDto )] ); + + /// Maps a to a . + public static StepDependencyDto ToDepDto( WorkflowStepDependency dep ) => + new( StepId: dep.StepId, DependsOnStepId: dep.DependsOnStepId ); + + /// Maps a to a entity. + public static WorkflowStep ToStepEntity( long workflowId, WorkflowStepCreateRequest request ) => + new( ) { + WorkflowId = workflowId, + TaskId = request.TaskId, + Order = request.Order, + ControlStatement = Enum.Parse( request.ControlStatement, ignoreCase: true ), + ConditionExpression = request.ConditionExpression, + MaxIterations = request.MaxIterations, + AgentConnectionIdOverride = request.AgentConnectionIdOverride, + DependencyMode = Enum.Parse( request.DependencyMode, ignoreCase: true ), + }; + + /// Maps a entity to a . + public static WorkflowRunDto ToRunDto( WorkflowRun run ) => + new( + Id: run.Id, + WorkflowId: run.WorkflowId, + StartTime: run.StartTime, + EndTime: run.EndTime, + Status: run.Status.ToString( ) ); + + /// Maps a entity (with Jobs) to a . + public static WorkflowRunDetailDto ToRunDetailDto( WorkflowRun run ) => + new( + Id: run.Id, + WorkflowId: run.WorkflowId, + StartTime: run.StartTime, + EndTime: run.EndTime, + Status: run.Status.ToString( ), + Jobs: [.. run.Jobs.Select( TaskMapper.ToJobDto )] ); +} diff --git a/src/Werkr.Api/Program.cs b/src/Werkr.Api/Program.cs new file mode 100644 index 0000000..753032a --- /dev/null +++ b/src/Werkr.Api/Program.cs @@ -0,0 +1,248 @@ +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.EntityFrameworkCore; +using Serilog; +using Serilog.Settings.Configuration; +using Werkr.Api.Authorization; +using Werkr.Api.Endpoints; +using Werkr.Api.Services; +using Werkr.Common; +using Werkr.Common.Auth; +using Werkr.Common.Configuration; +using Werkr.Common.Extensions; +using Werkr.Core.Communication; +using Werkr.Core.Cryptography; +using Werkr.Core.Registration; +using Werkr.Core.Scheduling; +using Werkr.Core.Tasks; +using Werkr.Data; +using Werkr.ServiceDefaults; + +namespace Werkr.Api; + +/// Application entry point for the Werkr API service. +public class Program { + /// Main entry point. + /// Command-line arguments. + public static async Task Main( string[] args ) { + Log.Logger = new LoggerConfiguration( ) + .WriteTo.Console( ) + .CreateBootstrapLogger( ); + + try { + Log.Information( "Starting Werkr API Service..." ); + + string version = System.Reflection.CustomAttributeExtensions + .GetCustomAttribute( + System.Reflection.Assembly.GetEntryAssembly( )! ) + ?.InformationalVersion ?? "unknown"; + Log.Information( "Werkr API version {Version}", version ); + + // Platform validation gate — fail fast if crypto is not supported + EncryptionProvider.ValidatePlatformCryptoSupport( ); + Log.Information( "Platform cryptographic validation passed." ); + + WebApplicationBuilder builder = WebApplication.CreateBuilder( args ); + + _ = builder.Configuration.AddWerkrConfigPath( "Api" ); + + // Serilog (ConfigurationReaderOptions required for single-file publish) + ConfigurationReaderOptions readerOptions = new( + typeof( Serilog.ConsoleLoggerConfigurationExtensions ).Assembly, + typeof( Serilog.FileLoggerConfigurationExtensions ).Assembly, + typeof( Serilog.Sinks.OpenTelemetry.OtlpProtocol ).Assembly ); + _ = builder.Host.UseSerilog( ( ctx, lc ) => lc + .ReadFrom.Configuration( ctx.Configuration, readerOptions ) ); + + // Add service defaults & Aspire client integrations. + _ = builder.AddServiceDefaults( ); + + // Add services to the container. + _ = builder.Services.AddProblemDetails( ); + + // OpenAPI + _ = builder.Services.AddOpenApi( ); + + // gRPC with Agent bearer-token validation + _ = builder.Services.AddGrpc( options => { + options.Interceptors.Add( ); + } ); + + // Kestrel endpoint configuration. + // All endpoints use TLS with Http1AndHttp2 — ALPN negotiates HTTP/2 + // for gRPC automatically. In containers, the TLS certificate is mounted + // from the host and configured via ASPNETCORE_Kestrel__Certificates__Default__* + // environment variables. Outside containers, the dev cert handles TLS. + _ = builder.WebHost.ConfigureKestrel( options => { + options.ConfigureEndpointDefaults( listenOptions => { + listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2; + } ); + } ); + + // Database — provider is configurable via Database:Provider (default: Postgres) + string connectionString = builder.Configuration.GetConnectionString( "werkrdb" ) ?? string.Empty; + DatabaseProvider dbProvider = Enum.TryParse( + builder.Configuration["Database:Provider"], ignoreCase: true, out DatabaseProvider parsed ) + ? parsed : DatabaseProvider.Postgres; + _ = builder.Services.AddWerkrDbContext( dbProvider, connectionString ); + + // Configuration + WerkrConfiguration werkrConfig = new( ); + builder.Configuration.GetSection( WerkrConfiguration.SectionName ).Bind( werkrConfig ); + + // JWT Bearer Authentication (validation only — Server issues tokens) + _ = builder.Services.AddAuthentication( options => { + options.DefaultAuthenticateScheme = + Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = + Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerDefaults.AuthenticationScheme; + } ) + .AddJwtBearer( options => { + options.TokenValidationParameters = + JwtValidationConfigurator.GetParameters( builder.Configuration ); + } ); + + // Permission-based authorization (claims-based handler — no identity DB queries) + _ = builder.Services.AddAuthorization( options => options.AddWerkrPermissionPolicies( ) ); + _ = builder.Services.AddSingleton( ); + + // Named HttpClient for proxying token requests to the Server + _ = builder.Services.AddHttpClient( "ServerService", client => { + client.BaseAddress = new Uri( "https://server" ); + } ); + + // Registration service + // ServerUrl may be set explicitly in config; if not, resolve from the running server's addresses at runtime. + _ = builder.Services.AddScoped( sp => { + WerkrDbContext dbContext = sp.GetRequiredService( ); + ILogger logger = sp.GetRequiredService>( ); + string serverUrl = werkrConfig.ServerUrl; + if (string.IsNullOrWhiteSpace( serverUrl )) { + IServer server = sp.GetRequiredService( ); + IServerAddressesFeature? addressesFeature = server.Features.Get( ); + serverUrl = addressesFeature?.Addresses + .FirstOrDefault( a => a.StartsWith( "https://", StringComparison.OrdinalIgnoreCase ) ) + ?? addressesFeature?.Addresses.FirstOrDefault( ) + ?? throw new InvalidOperationException( + "Werkr:ServerUrl is not configured and no server addresses are available. " + + "Set 'Werkr:ServerUrl' in appsettings.json or ensure the server has bound addresses." ); + } + return new RegistrationService( dbContext, logger, serverUrl ); + } ); + + // Bundle expiration background service + _ = builder.Services.AddHostedService( sp => { + IServiceScopeFactory scopeFactory = sp.GetRequiredService( ); + ILogger logger = sp.GetRequiredService>( ); + return new BundleExpirationService( scopeFactory, logger ); + } ); + + // Agent connection manager (Singleton — caches gRPC channels) + _ = builder.Services.AddSingleton( ); + + // Agent health check background service — keeps DB status current + _ = builder.Services.AddHostedService( sp => { + IServiceScopeFactory scopeFactory = sp.GetRequiredService( ); + AgentConnectionManager connectionManager = sp.GetRequiredService( ); + ILogger logger = + sp.GetRequiredService>( ); + return new Werkr.Core.Health.AgentHealthCheckService( scopeFactory, connectionManager, logger ); + } ); + + // Command dispatcher (Scoped — one per request) + _ = builder.Services.AddScoped( ); + _ = builder.Services.AddScoped( sp => sp.GetRequiredService( ) ); + + // Schedule service (Scoped — one per request) + _ = builder.Services.AddScoped( ); + + // Task & Job services (Scoped — one per request) + _ = builder.Services.Configure( + builder.Configuration.GetSection( JobOutputOptions.SectionName ) ); + _ = builder.Services.AddScoped( ); + _ = builder.Services.AddScoped( ); + _ = builder.Services.AddScoped( ); + _ = builder.Services.AddScoped( ); + _ = builder.Services.AddScoped( ); + + // Workflow services (Scoped — one per request) + _ = builder.Services.AddScoped( ); + _ = builder.Services.AddScoped( ); + _ = builder.Services.AddScoped( ); + _ = builder.Services.AddSingleton( ); + + // Schedule invalidation dispatcher (Scoped — sends push notifications to agents) + _ = builder.Services.AddScoped( ); + + // Holiday calendar services (Scoped — one per request) + _ = builder.Services.AddScoped( ); + _ = builder.Services.AddScoped( ); + + // Audit log cleanup + _ = builder.Services.Configure( builder.Configuration.GetSection( "AuditLog" ) ); + _ = builder.Services.AddHostedService( ); + + // Key rotation background service — rotates SharedKey for all connected agents + _ = builder.Services.AddSingleton( sp => { + IServiceScopeFactory scopeFactory = sp.GetRequiredService( ); + AgentConnectionManager connectionManager = sp.GetRequiredService( ); + ILogger logger = + sp.GetRequiredService>( ); + return new KeyRotationService( scopeFactory, connectionManager, logger ); + } ); + _ = builder.Services.AddHostedService( sp => sp.GetRequiredService( ) ); + + WebApplication app = builder.Build( ); + + // Auto-migrate app DB in development (API owns the application database) + if (app.Environment.IsDevelopment( )) { + using IServiceScope scope = app.Services.CreateScope( ); + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + await db.Database.MigrateAsync( ); + } + + // Seed system holiday calendars + await Werkr.Data.Seeding.HolidayCalendarSeeder.SeedAsync( app.Services ); + + // Configure the HTTP request pipeline. + _ = app.UseExceptionHandler( ); + + if (app.Environment.IsDevelopment( )) { + _ = app.MapOpenApi( ); + } + + // Authentication & Authorization middleware + _ = app.UseAuthentication( ); + _ = app.UseAuthorization( ); + + // gRPC services + _ = app.MapGrpcService( ); + _ = app.MapGrpcService( ); + _ = app.MapGrpcService( ); + _ = app.MapGrpcService( ); + + // REST endpoints + _ = app.MapStatusEndpoints( ); + _ = app.MapAuthProxyEndpoints( ); + _ = app.MapRegistrationEndpoints( ); + _ = app.MapAgentEndpoints( ); + _ = app.MapDiagnosticsEndpoints( ); + _ = app.MapScheduleEndpoints( ); + _ = app.MapTaskEndpoints( ); + _ = app.MapJobEndpoints( ); + _ = app.MapSettingsEndpoints( ); + _ = app.MapShellEndpoints( ); + _ = app.MapWorkflowEndpoints( ); + _ = app.MapHolidayCalendarEndpoints( ); + + _ = app.MapDefaultEndpoints( ); + + app.Run( ); + } catch (Exception ex) { + Log.Fatal( ex, "Werkr API Service terminated unexpectedly." ); + } finally { + Log.CloseAndFlush( ); + } + } +} diff --git a/src/Werkr.Api/Protos/Registration.proto b/src/Werkr.Api/Protos/Registration.proto new file mode 100644 index 0000000..ca1ff28 --- /dev/null +++ b/src/Werkr.Api/Protos/Registration.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; +option csharp_namespace = "Werkr.Api.Protos"; +package werkr.api.protos; + +// The Server (Werkr.Api) hosts this service. +// The Agent calls RegisterAgent after decrypting the admin-carried bundle. +service AgentRegistration { + rpc RegisterAgent (RegisterAgentRequest) returns (RegisterAgentResponse); +} + +message RegisterAgentRequest { + // 16-byte correlation token from the bundle — identifies which RegistrationBundle to complete + bytes bundle_id = 1; + + // Agent's serialized RSA public key, hybrid-encrypted with the Server's public key: + // AES-GCM encrypts the key bytes, RSA-OAEP-SHA3-512 encrypts only the AES key material. + // Format: rsaEncryptedAesKey | nonce | tag | ciphertext + bytes encrypted_agent_public_key = 2; + + // Agent's gRPC endpoint URL (e.g., "https://agent-host:5001") + string agent_url = 3; + + // Human-readable Agent name + string agent_name = 4; +} + +message RegisterAgentResponse { + // Whether registration succeeded + bool success = 1; + + // Hybrid-encrypted RegistrationResponsePayload (JSON -> HybridEncrypt with Agent's public key). + // Contains: API key (raw string for Agent to store) + SharedKey (32-byte AES-256 symmetric key + // for ongoing message encryption). Empty on failure. + bytes encrypted_registration_data = 2; + + // Human-readable status message (success confirmation or error description) + string message = 3; +} diff --git a/src/Werkr.Api/Services/AuditLogCleanupService.cs b/src/Werkr.Api/Services/AuditLogCleanupService.cs new file mode 100644 index 0000000..83f399d --- /dev/null +++ b/src/Werkr.Api/Services/AuditLogCleanupService.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +using Werkr.Data; + +namespace Werkr.Api.Services; + +/// +/// Background service that runs daily to delete +/// records older than the configured retention period. +/// +public sealed partial class AuditLogCleanupService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger +) : BackgroundService { + private readonly IServiceScopeFactory _scopeFactory = scopeFactory; + private readonly AuditLogOptions _options = options.Value; + private readonly ILogger _logger = logger; + + /// + protected override async Task ExecuteAsync( CancellationToken stoppingToken ) { + LogCleanupStarted( _logger, _options.RetentionDays ); + + while (!stoppingToken.IsCancellationRequested) { + try { + await CleanupAsync( stoppingToken ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + LogCleanupError( _logger, ex ); + } + + // Wait 24 hours before next cleanup cycle + await Task.Delay( TimeSpan.FromHours( 24 ), stoppingToken ); + } + } + + private async Task CleanupAsync( CancellationToken ct ) { + using IServiceScope scope = _scopeFactory.CreateScope( ); + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + + DateTime cutoff = DateTime.UtcNow.AddDays( -_options.RetentionDays ); + + int deleted = await db.ScheduleAuditLogs + .Where( log => log.CreatedUtc < cutoff ) + .ExecuteDeleteAsync( ct ); + + if (deleted > 0) { + LogRecordsDeleted( _logger, deleted, _options.RetentionDays ); + } + } + + [LoggerMessage( Level = LogLevel.Information, + Message = "Audit log cleanup service started (retention: {RetentionDays} days)" )] + private static partial void LogCleanupStarted( ILogger logger, int retentionDays ); + + [LoggerMessage( Level = LogLevel.Information, + Message = "Audit log cleanup deleted {Count} records older than {RetentionDays} days" )] + private static partial void LogRecordsDeleted( ILogger logger, int count, int retentionDays ); + + [LoggerMessage( Level = LogLevel.Error, + Message = "Audit log cleanup encountered an error" )] + private static partial void LogCleanupError( ILogger logger, Exception ex ); +} diff --git a/src/Werkr.Api/Services/AuditLogOptions.cs b/src/Werkr.Api/Services/AuditLogOptions.cs new file mode 100644 index 0000000..aaaaa4c --- /dev/null +++ b/src/Werkr.Api/Services/AuditLogOptions.cs @@ -0,0 +1,13 @@ +namespace Werkr.Api.Services; + +/// +/// Configuration options for schedule audit log retention. +/// Bound from the "AuditLog" configuration section. +/// +public sealed class AuditLogOptions { + /// + /// Number of days to retain audit log records before automatic cleanup. + /// Default is 90 days. + /// + public int RetentionDays { get; set; } = 90; +} diff --git a/src/Werkr.Api/Services/HolidayCalendarService.cs b/src/Werkr.Api/Services/HolidayCalendarService.cs new file mode 100644 index 0000000..d13b2f9 --- /dev/null +++ b/src/Werkr.Api/Services/HolidayCalendarService.cs @@ -0,0 +1,406 @@ +using System.ComponentModel.DataAnnotations; + +using Microsoft.EntityFrameworkCore; + +using Werkr.Core.Scheduling; +using Werkr.Data; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Validation; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Api.Services; + +/// +/// CRUD service for holiday calendars, rules, manual dates, schedule attachment, and cache management. +/// Lives in Werkr.Api (not Werkr.Core) to avoid a circular dependency with +/// . +/// +public sealed class HolidayCalendarService( + WerkrDbContext db, + HolidayDateService dateService, + ScheduleInvalidationDispatcher invalidationDispatcher, + ILogger logger ) { + + private readonly ILogger _logger = logger; + + // ── Calendar-Level Operations ────────────────────────────────────────────── + + /// Create a user-owned calendar (IsSystemCalendar = false). + public async Task CreateAsync( HolidayCalendar calendar, CancellationToken ct = default ) { + calendar.IsSystemCalendar = false; + calendar.CreatedUtc = DateTime.UtcNow; + calendar.UpdatedUtc = DateTime.UtcNow; + _ = db.HolidayCalendars.Add( calendar ); + _ = await db.SaveChangesAsync( ct ); + return calendar; + } + + /// Update name/description. Blocked for system calendars. + public async Task UpdateAsync( Guid id, HolidayCalendar updated, CancellationToken ct = default ) { + HolidayCalendar existing = await GetOrThrowAsync( id, ct ); + ThrowIfSystem( existing ); + + existing.Name = updated.Name; + existing.Description = updated.Description; + existing.UpdatedUtc = DateTime.UtcNow; + _ = await db.SaveChangesAsync( ct ); + return existing; + } + + /// Delete calendar + cascade. Blocked for system calendars. + public async Task DeleteAsync( Guid id, CancellationToken ct = default ) { + HolidayCalendar existing = await GetOrThrowAsync( id, ct ); + ThrowIfSystem( existing ); + + // Detach from all schedules first and dispatch invalidation + List links = await db.ScheduleHolidayCalendars + .Where( l => l.HolidayCalendarId == id ) + .ToListAsync( ct ); + + foreach (ScheduleHolidayCalendar link in links) { + _ = Task.Run( ( ) => invalidationDispatcher.InvalidateAsync( link.ScheduleId, CancellationToken.None ), ct ); + } + db.ScheduleHolidayCalendars.RemoveRange( links ); + + _ = db.HolidayCalendars.Remove( existing ); + _ = await db.SaveChangesAsync( ct ); + } + + /// Load calendar with rules and dates. + public async Task GetByIdAsync( Guid id, CancellationToken ct = default ) => + await db.HolidayCalendars + .Include( c => c.Rules ) + .Include( c => c.Dates ) + .FirstOrDefaultAsync( c => c.Id == id, ct ); + + /// List all calendars with summary counts. + public async Task> GetAllAsync( CancellationToken ct = default ) => + await db.HolidayCalendars + .Include( c => c.Rules ) + .Include( c => c.ScheduleLinks ) + .AsNoTracking( ) + .OrderBy( c => c.Name ) + .ToListAsync( ct ); + + /// Deep-copy rules into new user-owned calendar. Materialized dates NOT cloned. + public async Task CloneAsync( Guid sourceId, string newName, CancellationToken ct = default ) { + HolidayCalendar source = await db.HolidayCalendars + .Include( c => c.Rules ) + .FirstOrDefaultAsync( c => c.Id == sourceId, ct ) + ?? throw new KeyNotFoundException( $"Calendar {sourceId} not found." ); + + HolidayCalendar clone = new( ) { + Id = Guid.NewGuid( ), + Name = newName, + Description = source.Description, + IsSystemCalendar = false, + CreatedUtc = DateTime.UtcNow, + UpdatedUtc = DateTime.UtcNow, + }; + + foreach (HolidayRule rule in source.Rules) { + clone.Rules.Add( new HolidayRule { + HolidayCalendarId = clone.Id, + Name = rule.Name, + RuleType = rule.RuleType, + Month = rule.Month, + Day = rule.Day, + DayOfWeek = rule.DayOfWeek, + WeekNumber = rule.WeekNumber, + WindowStart = rule.WindowStart, + WindowEnd = rule.WindowEnd, + WindowTimeZoneId = rule.WindowTimeZoneId, + ObservanceRule = rule.ObservanceRule, + YearStart = rule.YearStart, + YearEnd = rule.YearEnd, + } ); + } + + _ = db.HolidayCalendars.Add( clone ); + _ = await db.SaveChangesAsync( ct ); + return clone; + } + + // ── Rule Operations ──────────────────────────────────────────────────────── + + /// Get a single rule by ID. + public async Task GetRuleAsync( Guid calendarId, long ruleId, CancellationToken ct = default ) => + await db.HolidayRules + .FirstOrDefaultAsync( r => r.HolidayCalendarId == calendarId && r.Id == ruleId, ct ); + + /// Get all rules for a calendar. + public async Task> GetRulesAsync( Guid calendarId, CancellationToken ct = default ) => + await db.HolidayRules + .Where( r => r.HolidayCalendarId == calendarId ) + .AsNoTracking( ) + .OrderBy( r => r.Month ) + .ThenBy( r => r.Day ) + .ToListAsync( ct ); + + /// Add rule with validation and cache invalidation. Blocked for system calendars. (H18) + public async Task AddRuleAsync( Guid calendarId, HolidayRule rule, CancellationToken ct = default ) { + HolidayCalendar calendar = await GetOrThrowAsync( calendarId, ct ); + ThrowIfSystem( calendar ); + + rule.HolidayCalendarId = calendarId; + ValidationResult? result = HolidayRuleValidator.Validate( rule ); + if (result != ValidationResult.Success) { + throw new ValidationException( result!.ErrorMessage ); + } + + _ = db.HolidayRules.Add( rule ); + _ = await db.SaveChangesAsync( ct ); + + await dateService.InvalidateCacheAsync( calendarId, ct ); + await InvalidateLinkedSchedulesAsync( calendarId, ct ); + return rule; + } + + /// Update rule with validation and cache invalidation. Blocked for system calendars. (H18) + public async Task UpdateRuleAsync( Guid calendarId, long ruleId, HolidayRule updated, CancellationToken ct = default ) { + HolidayCalendar calendar = await GetOrThrowAsync( calendarId, ct ); + ThrowIfSystem( calendar ); + + HolidayRule existing = await db.HolidayRules + .FirstOrDefaultAsync( r => r.HolidayCalendarId == calendarId && r.Id == ruleId, ct ) + ?? throw new KeyNotFoundException( $"Rule {ruleId} not found in calendar {calendarId}." ); + + existing.Name = updated.Name; + existing.RuleType = updated.RuleType; + existing.Month = updated.Month; + existing.Day = updated.Day; + existing.DayOfWeek = updated.DayOfWeek; + existing.WeekNumber = updated.WeekNumber; + existing.WindowStart = updated.WindowStart; + existing.WindowEnd = updated.WindowEnd; + existing.WindowTimeZoneId = updated.WindowTimeZoneId; + existing.ObservanceRule = updated.ObservanceRule; + existing.YearStart = updated.YearStart; + existing.YearEnd = updated.YearEnd; + + ValidationResult? result = HolidayRuleValidator.Validate( existing ); + if (result != ValidationResult.Success) { + throw new ValidationException( result!.ErrorMessage ); + } + + _ = await db.SaveChangesAsync( ct ); + + await dateService.InvalidateCacheAsync( calendarId, ct ); + await InvalidateLinkedSchedulesAsync( calendarId, ct ); + return existing; + } + + /// Remove rule + generated dates. Cache invalidation. Blocked for system calendars. (H18) + public async Task RemoveRuleAsync( Guid calendarId, long ruleId, CancellationToken ct = default ) { + HolidayCalendar calendar = await GetOrThrowAsync( calendarId, ct ); + ThrowIfSystem( calendar ); + + HolidayRule rule = await db.HolidayRules + .FirstOrDefaultAsync( r => r.HolidayCalendarId == calendarId && r.Id == ruleId, ct ) + ?? throw new KeyNotFoundException( $"Rule {ruleId} not found in calendar {calendarId}." ); + + _ = db.HolidayRules.Remove( rule ); + _ = await db.SaveChangesAsync( ct ); + + await dateService.InvalidateCacheAsync( calendarId, ct ); + await InvalidateLinkedSchedulesAsync( calendarId, ct ); + } + + /// Compute dates for a rule without saving to the database. + public static IReadOnlyList PreviewRuleAsync( HolidayRule rule, int startYear, int endYear ) { + List results = []; + for (int year = startYear; year <= endYear; year++) { + results.AddRange( HolidayCalculator.ComputeDatesForYear( rule, year ) ); + } + return results; + } + + // ── Manual Date Operations ───────────────────────────────────────────────── + + /// Get a single manual date by ID. + public async Task GetManualDateAsync( Guid calendarId, long dateId, CancellationToken ct = default ) => + await db.HolidayDates + .FirstOrDefaultAsync( d => d.HolidayCalendarId == calendarId && d.Id == dateId, ct ); + + /// Get all manual dates (HolidayRuleId == null) for a calendar. + public async Task> GetManualDatesAsync( Guid calendarId, CancellationToken ct = default ) => + await db.HolidayDates + .Where( d => d.HolidayCalendarId == calendarId && d.HolidayRuleId == null ) + .AsNoTracking( ) + .OrderBy( d => d.Date ) + .ToListAsync( ct ); + + /// Add a manual date. Blocked for system calendars. (H18) + public async Task AddManualDateAsync( Guid calendarId, HolidayDate date, CancellationToken ct = default ) { + HolidayCalendar calendar = await GetOrThrowAsync( calendarId, ct ); + ThrowIfSystem( calendar ); + + date.HolidayCalendarId = calendarId; + date.HolidayRuleId = null; + _ = db.HolidayDates.Add( date ); + _ = await db.SaveChangesAsync( ct ); + + await InvalidateLinkedSchedulesAsync( calendarId, ct ); + return date; + } + + /// Update a manual date. Blocked for system calendars. (H18) + public async Task UpdateManualDateAsync( Guid calendarId, long dateId, HolidayDate updated, CancellationToken ct = default ) { + HolidayCalendar calendar = await GetOrThrowAsync( calendarId, ct ); + ThrowIfSystem( calendar ); + + HolidayDate existing = await db.HolidayDates + .FirstOrDefaultAsync( d => d.HolidayCalendarId == calendarId && d.Id == dateId, ct ) + ?? throw new KeyNotFoundException( $"Date {dateId} not found in calendar {calendarId}." ); + + existing.Date = updated.Date; + existing.Name = updated.Name; + existing.Year = updated.Year; + existing.WindowStart = updated.WindowStart; + existing.WindowEnd = updated.WindowEnd; + existing.WindowTimeZoneId = updated.WindowTimeZoneId; + _ = await db.SaveChangesAsync( ct ); + + await InvalidateLinkedSchedulesAsync( calendarId, ct ); + return existing; + } + + /// Remove a manual date. Blocked for system calendars. (H18) + public async Task RemoveManualDateAsync( Guid calendarId, long dateId, CancellationToken ct = default ) { + HolidayCalendar calendar = await GetOrThrowAsync( calendarId, ct ); + ThrowIfSystem( calendar ); + + HolidayDate date = await db.HolidayDates + .FirstOrDefaultAsync( d => d.HolidayCalendarId == calendarId && d.Id == dateId, ct ) + ?? throw new KeyNotFoundException( $"Date {dateId} not found in calendar {calendarId}." ); + + _ = db.HolidayDates.Remove( date ); + _ = await db.SaveChangesAsync( ct ); + + await InvalidateLinkedSchedulesAsync( calendarId, ct ); + } + + /// Bulk add manual dates. Blocked for system calendars. (H18) + public async Task> BulkAddManualDatesAsync( + Guid calendarId, IReadOnlyList dates, CancellationToken ct = default ) { + HolidayCalendar calendar = await GetOrThrowAsync( calendarId, ct ); + ThrowIfSystem( calendar ); + + foreach (HolidayDate date in dates) { + date.HolidayCalendarId = calendarId; + date.HolidayRuleId = null; + _ = db.HolidayDates.Add( date ); + } + _ = await db.SaveChangesAsync( ct ); + + await InvalidateLinkedSchedulesAsync( calendarId, ct ); + return dates; + } + + // ── Preview & Materialization ────────────────────────────────────────────── + + /// Compute all dates (rules + manual) for a year range without persisting. + public async Task> PreviewDatesAsync( + Guid calendarId, int startYear, int endYear, CancellationToken ct = default ) { + + HolidayCalendar calendar = await db.HolidayCalendars + .Include( c => c.Rules ) + .Include( c => c.Dates.Where( d => d.HolidayRuleId == null ) ) + .FirstOrDefaultAsync( c => c.Id == calendarId, ct ) + ?? throw new KeyNotFoundException( $"Calendar {calendarId} not found." ); + + List results = []; + + // Computed from rules + results.AddRange( HolidayCalculator.ComputeDatesForRange( calendar, startYear, endYear ) ); + + // Manual dates in range + results.AddRange( calendar.Dates.Where( d => + d.Year >= startYear && d.Year <= endYear ) ); + + return [.. results.OrderBy( d => d.Date ).ThenBy( d => d.WindowStart )]; + } + + /// Delegates to . + public Task MaterializeDatesAsync( Guid calendarId, int startYear, int endYear, CancellationToken ct = default ) => + dateService.MaterializeDatesAsync( calendarId, startYear, endYear, ct ); + + // ── Schedule Attachment ──────────────────────────────────────────────────── + + /// Attach a calendar to a schedule. Fails if already attached (H5). Dispatches invalidation (H18). + public async Task AttachToScheduleAsync( + Guid scheduleId, Guid calendarId, HolidayCalendarMode mode, CancellationToken ct = default ) { + + // Verify calendar exists + bool calendarExists = await db.HolidayCalendars.AnyAsync( c => c.Id == calendarId, ct ); + if (!calendarExists) { + throw new KeyNotFoundException( $"Calendar {calendarId} not found." ); + } + + // Verify schedule exists + bool scheduleExists = await db.Schedules.AnyAsync( s => s.Id == scheduleId, ct ); + if (!scheduleExists) { + throw new KeyNotFoundException( $"Schedule {scheduleId} not found." ); + } + + ScheduleHolidayCalendar link = new( ) { + ScheduleId = scheduleId, + HolidayCalendarId = calendarId, + Mode = mode, + }; + + _ = db.ScheduleHolidayCalendars.Add( link ); + _ = await db.SaveChangesAsync( ct ); + + _ = Task.Run( ( ) => invalidationDispatcher.InvalidateAsync( scheduleId, CancellationToken.None ), ct ); + return link; + } + + /// Detach calendar from a schedule. Dispatches invalidation (H18). + public async Task DetachFromScheduleAsync( Guid scheduleId, CancellationToken ct = default ) { + ScheduleHolidayCalendar? link = await db.ScheduleHolidayCalendars + .FirstOrDefaultAsync( l => l.ScheduleId == scheduleId, ct ); + + if (link is null) { + return; + } + + _ = db.ScheduleHolidayCalendars.Remove( link ); + _ = await db.SaveChangesAsync( ct ); + + _ = Task.Run( ( ) => invalidationDispatcher.InvalidateAsync( scheduleId, CancellationToken.None ), ct ); + } + + /// Get attached calendar info + mode for a schedule. Returns null if none. + public async Task GetScheduleCalendarAsync( Guid scheduleId, CancellationToken ct = default ) => + await db.ScheduleHolidayCalendars + .Include( l => l.Calendar ) + .FirstOrDefaultAsync( l => l.ScheduleId == scheduleId, ct ); + + // ── Private Helpers ──────────────────────────────────────────────────────── + + private async Task GetOrThrowAsync( Guid id, CancellationToken ct ) => + await db.HolidayCalendars.FindAsync( [id], ct ) + ?? throw new KeyNotFoundException( $"Calendar {id} not found." ); + + private static void ThrowIfSystem( HolidayCalendar calendar ) { + if (calendar.IsSystemCalendar) { + throw new InvalidOperationException( $"Calendar '{calendar.Name}' is a system calendar and cannot be modified." ); + } + } + + /// + /// Decision H18: Queries schedule links for the affected calendar and dispatches + /// invalidation for each linked schedule via fire-and-forget. + /// + private async Task InvalidateLinkedSchedulesAsync( Guid calendarId, CancellationToken ct ) { + List scheduleIds = await db.ScheduleHolidayCalendars + .Where( l => l.HolidayCalendarId == calendarId ) + .Select( l => l.ScheduleId ) + .ToListAsync( ct ); + + foreach (Guid scheduleId in scheduleIds) { + _ = Task.Run( ( ) => invalidationDispatcher.InvalidateAsync( scheduleId, CancellationToken.None ), ct ); + } + } +} diff --git a/src/Werkr.Api/Services/JobReportingGrpcService.cs b/src/Werkr.Api/Services/JobReportingGrpcService.cs new file mode 100644 index 0000000..5b463a8 --- /dev/null +++ b/src/Werkr.Api/Services/JobReportingGrpcService.cs @@ -0,0 +1,104 @@ +using Grpc.Core; + +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Data; +using Werkr.Data.Entities.Registration; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Api.Services; + +/// +/// gRPC service for receiving job results reported by Agents. +/// Agents call after completing a scheduled task execution. +/// All RPCs use . +/// +/// Database context. +/// Logger instance. +public sealed class JobReportingGrpcService( + WerkrDbContext dbContext, + ILogger logger +) : JobReporting.JobReportingBase { + + /// + /// Receives a job result from an agent and persists it as a entity. + /// + public override async Task ReportJobResult( + EncryptedEnvelope request, + ServerCallContext context ) { + + RegisteredConnection connection = GetConnection( context ); + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + + JobResultRequest inner = PayloadEncryptor.DecryptFromEnvelope( + request, connection.SharedKey ); + + if (string.IsNullOrWhiteSpace( inner.ConnectionId )) { + throw new RpcException( new Status( StatusCode.InvalidArgument, "Connection ID is required." ) ); + } + + Guid connectionId = Guid.Parse( inner.ConnectionId ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Receiving job result from agent {AgentId} for task {TaskId}.", + inner.ConnectionId, inner.TaskId.ToString( ) ); + } + + // Map error category + ErrorCategory errorCategory = Enum.IsDefined( typeof( ErrorCategory ), inner.ErrorCategory ) + ? (ErrorCategory) inner.ErrorCategory + : ErrorCategory.Unknown; + + // Parse timestamps + DateTime startTime = DateTime.TryParse( inner.StartTime, out DateTime st ) + ? DateTime.SpecifyKind( st, DateTimeKind.Utc ) + : DateTime.UtcNow; + DateTime? endTime = DateTime.TryParse( inner.EndTime, out DateTime et ) + ? DateTime.SpecifyKind( et, DateTimeKind.Utc ) + : null; + + // Parse workflow run ID if provided + Guid? workflowRunId = !string.IsNullOrWhiteSpace( inner.WorkflowRunId ) + && Guid.TryParse( inner.WorkflowRunId, out Guid wfRunId ) + ? wfRunId + : null; + + WerkrJob job = new( ) { + TaskId = inner.TaskId, + TaskSnapshot = inner.TaskSnapshot, + RuntimeSeconds = inner.RuntimeSeconds, + StartTime = startTime, + EndTime = endTime, + Success = inner.Success, + AgentConnectionId = connectionId, + ExitCode = inner.ExitCode, + ErrorCategory = errorCategory, + Output = string.IsNullOrWhiteSpace( inner.OutputPreview ) ? null : inner.OutputPreview, + OutputPath = inner.OutputPath, + WorkflowRunId = workflowRunId, + }; + + _ = dbContext.Jobs.Add( job ); + _ = await dbContext.SaveChangesAsync( context.CancellationToken ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Persisted job {JobId} from agent {AgentId}: Task={TaskId}, Success={Success}, Runtime={Runtime:F1}s.", + job.Id.ToString( ), inner.ConnectionId, inner.TaskId.ToString( ), + inner.Success.ToString( ), inner.RuntimeSeconds ); + } + + JobResultResponse response = new( ) { + Accepted = true, + JobId = job.Id.ToString( ), + }; + return PayloadEncryptor.EncryptToEnvelope( response, connection.SharedKey, keyId ); + } + + private static RegisteredConnection GetConnection( ServerCallContext context ) { + return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection + ? connection + : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); + } +} diff --git a/src/Werkr.Api/Services/RegistrationGrpcService.cs b/src/Werkr.Api/Services/RegistrationGrpcService.cs new file mode 100644 index 0000000..ecb23a6 --- /dev/null +++ b/src/Werkr.Api/Services/RegistrationGrpcService.cs @@ -0,0 +1,58 @@ +using Google.Protobuf; + +using Grpc.Core; + +using Werkr.Api.Protos; +using Werkr.Core.Cryptography; +using Werkr.Core.Registration; +using Werkr.Core.Registration.Models; + +namespace Werkr.Api.Services; + +/// +/// gRPC service endpoint for Agent registration. +/// Pure pass-through to — zero business logic in API layer. +/// +/// +/// Creates a new . +/// +/// The core registration service. +/// Logger for diagnostics. +public class RegistrationGrpcService( + RegistrationService registrationService, + ILogger logger +) : AgentRegistration.AgentRegistrationBase { + + /// + /// Handles the Agent's RegisterAgent gRPC call. + /// + /// The registration request from the Agent. + /// The gRPC server call context. + /// The registration response. + public override async Task RegisterAgent( + RegisterAgentRequest request, + ServerCallContext context ) { + try { + (AgentRegistrationResult result, byte[]? encryptedResponseData) = await registrationService.CompleteRegistrationAsync( + request.BundleId.ToByteArray( ), + request.EncryptedAgentPublicKey.ToByteArray( ), + request.AgentUrl, + request.AgentName, + context.CancellationToken ); + + return new RegisterAgentResponse { + Success = result.Success, + EncryptedRegistrationData = encryptedResponseData is not null + ? ByteString.CopyFrom( encryptedResponseData ) + : ByteString.Empty, + Message = result.ErrorMessage ?? string.Empty, + }; + } catch (WerkrCryptoException ex) { + logger.LogError( ex, "Cryptographic error during Agent registration." ); + throw new RpcException( new Status( StatusCode.InvalidArgument, ex.Message ) ); + } catch (Exception ex) { + logger.LogError( ex, "Unexpected error during Agent registration." ); + throw new RpcException( new Status( StatusCode.Internal, "Internal registration error." ) ); + } + } +} diff --git a/src/Werkr.Api/Services/ScheduleInvalidationDispatcher.cs b/src/Werkr.Api/Services/ScheduleInvalidationDispatcher.cs new file mode 100644 index 0000000..2a5dcbe --- /dev/null +++ b/src/Werkr.Api/Services/ScheduleInvalidationDispatcher.cs @@ -0,0 +1,134 @@ +using Grpc.Core; +using Grpc.Net.Client; + +using Microsoft.EntityFrameworkCore; + +using Werkr.Common.Models; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Data; +using Werkr.Data.Entities.Registration; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Api.Services; + +/// +/// Server-side service that pushes schedule invalidation notifications to affected agents. +/// When a schedule is updated or deleted, this service identifies all agents whose tags +/// match the tasks using that schedule and sends them an +/// so they re-sync immediately. +/// +/// Manages gRPC channels to agents. +/// Factory for creating DI scopes to resolve . +/// Logger instance. +public sealed class ScheduleInvalidationDispatcher( + AgentConnectionManager connectionManager, + IServiceScopeFactory scopeFactory, + ILogger logger ) { + + /// + /// Notifies all affected agents that a schedule has been modified or deleted. + /// + /// Finds tasks referencing the schedule, identifies agents whose tags overlap, + /// and sends InvalidateSchedule to each. Failures are logged but do not throw. + /// + /// + /// The schedule ID that was changed. + /// Cancellation token. + public async Task InvalidateAsync( Guid scheduleId, CancellationToken ct = default ) { + using IServiceScope scope = scopeFactory.CreateScope( ); + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + + // Find tasks referencing this schedule to get their TargetTags + List affectedTasks = await db.Tasks + .AsNoTracking( ) + .Where( t => t.ScheduleId == scheduleId ) + .ToListAsync( ct ); + + if (affectedTasks.Count == 0) { + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "No tasks reference schedule {ScheduleId}. Skipping invalidation.", scheduleId ); + } + return; + } + + // Collect all unique target tags from affected tasks + HashSet allTargetTags = new( StringComparer.OrdinalIgnoreCase ); + foreach (WerkrTask task in affectedTasks) { + if (task.TargetTags is { Length: > 0 }) { + foreach (string tag in task.TargetTags) { + _ = allTargetTags.Add( tag ); + } + } + } + + // Find all connected agents + List agents = await db.RegisteredConnections + .AsNoTracking( ) + .Where( c => c.IsServer && c.Status == ConnectionStatus.Connected ) + .ToListAsync( ct ); + + // Filter agents whose tags overlap with the affected tasks' TargetTags + List affectedAgents = []; + foreach (RegisteredConnection agent in agents) { + string[]? agentTags = agent.Tags; + if (agentTags is null || agentTags.Length == 0) { + continue; + } + + bool hasOverlap = agentTags.Any( t => allTargetTags.Contains( t ) ); + if (hasOverlap) { + affectedAgents.Add( agent ); + } + } + + if (affectedAgents.Count == 0) { + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "No agents match tags for schedule {ScheduleId}. Skipping invalidation.", scheduleId ); + } + return; + } + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Sending schedule invalidation for {ScheduleId} to {AgentCount} agents.", + scheduleId, affectedAgents.Count ); + } + + // Send invalidation to each affected agent (fire-and-forget, log failures) + InvalidateScheduleRequest innerRequest = new( ) { + ScheduleId = scheduleId.ToString( ), + }; + + await Parallel.ForEachAsync( affectedAgents, ct, async ( agent, innerCt ) => { + try { + (GrpcChannel channel, RegisteredConnection connection) = + await connectionManager.GetChannelAsync( agent.Id, innerCt ); + CallOptions callOptions = AgentConnectionManager.CreateCallOptions( + connection, + timeout: TimeSpan.FromSeconds( 15 ), + cancellationToken: innerCt ); + + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( + innerRequest, connection.SharedKey, keyId ); + + ScheduleInvalidation.ScheduleInvalidationClient client = new( channel ); + EncryptedEnvelope responseEnvelope = await client.InvalidateScheduleAsync( envelope, callOptions ); + + InvalidateScheduleResponse response = PayloadEncryptor.DecryptFromEnvelope( + responseEnvelope, connection.SharedKey ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( + "Schedule invalidation sent to agent {AgentId}: acknowledged={Ack}.", + agent.Id, response.Acknowledged ); + } + } catch (Exception ex) { + logger.LogWarning( ex, + "Failed to send schedule invalidation to agent {AgentId} for schedule {ScheduleId}.", + agent.Id, scheduleId ); + } + } ); + } +} diff --git a/src/Werkr.Api/Services/ScheduleSyncGrpcService.cs b/src/Werkr.Api/Services/ScheduleSyncGrpcService.cs new file mode 100644 index 0000000..480521b --- /dev/null +++ b/src/Werkr.Api/Services/ScheduleSyncGrpcService.cs @@ -0,0 +1,360 @@ +using Grpc.Core; + +using Microsoft.EntityFrameworkCore; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Scheduling; +using Werkr.Data; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Models; +using Werkr.Data.Entities.Registration; +using Werkr.Data.Entities.Schedule; +using Werkr.Data.Entities.Tasks; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Api.Services; + +/// +/// gRPC service for Agent schedule synchronization. +/// Agents call to pull tasks and workflows +/// whose TargetTags intersect with the agent's tags. +/// All RPCs use . +/// +/// Database context. +/// Schedule service for loading composite schedules. +/// Holiday date materialization service. +/// Holiday calendar CRUD service. +/// Logger instance. +public sealed class ScheduleSyncGrpcService( + WerkrDbContext dbContext, + ScheduleService scheduleService, + HolidayDateService holidayDateService, + HolidayCalendarService holidayCalendarService, + ILogger logger +) : ScheduleSync.ScheduleSyncBase { + + /// + /// Returns all enabled tasks and workflows with schedules whose task TargetTags + /// intersect with the requesting agent's tags. + /// + public override async Task GetAssignedSchedules( + EncryptedEnvelope request, + ServerCallContext context ) { + + RegisteredConnection connection = GetConnection( context ); + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + + AgentScheduleRequest inner = PayloadEncryptor.DecryptFromEnvelope( + request, connection.SharedKey ); + + if (string.IsNullOrWhiteSpace( inner.ConnectionId )) { + throw new RpcException( new Status( StatusCode.InvalidArgument, "Connection ID is required." ) ); + } + + // Use the server-authoritative tags from the resolved connection (set by admin) + // rather than the agent-reported tags, which may be stale or empty. + HashSet agentTags = new( connection.Tags ?? [], StringComparer.OrdinalIgnoreCase ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( + "Agent {AgentId} requesting schedules with tags [{Tags}] (agent-reported: [{AgentReportedTags}]).", + inner.ConnectionId, string.Join( ", ", agentTags ), string.Join( ", ", inner.Tags ) ); + } + + AgentScheduleResponse response = new( ); + + // ── Standalone tasks with schedules ── + List tasks = await dbContext.Tasks + .AsNoTracking( ) + .Where( t => t.Enabled && t.ScheduleId != null ) + .ToListAsync( context.CancellationToken ); + + foreach (WerkrTask task in tasks) { + // Tag intersection (in-memory for JSON column compatibility) + if (!task.TargetTags.Any( tag => agentTags.Contains( tag.Trim( ) ) )) { + continue; + } + + Schedule? schedule = await scheduleService.GetByIdAsync( task.ScheduleId!.Value, context.CancellationToken ); + if (schedule is null) { + continue; + } + + ScheduledTaskDefinition taskDef = MapTaskDefinition( task, schedule ); + response.Tasks.Add( taskDef ); + } + + // ── Workflows with schedules ── + List workflows = await dbContext.Workflows + .AsNoTracking( ) + .Include( w => w.Steps ) + .ThenInclude( s => s.Task ) + .Include( w => w.Steps ) + .ThenInclude( s => s.Dependencies ) + .Where( w => w.Enabled && w.ScheduleId != null ) + .ToListAsync( context.CancellationToken ); + + foreach (Workflow workflow in workflows) { + // Check if any task in the workflow matches the agent's tags + bool anyMatch = workflow.Steps.Any( step => + step.Task is not null && + step.Task.TargetTags.Any( tag => agentTags.Contains( tag.Trim( ) ) ) ); + if (!anyMatch) { + continue; + } + + Schedule? schedule = await scheduleService.GetByIdAsync( workflow.ScheduleId!.Value, context.CancellationToken ); + if (schedule is null) { + continue; + } + + ScheduledWorkflowDefinition workflowDef = MapWorkflowDefinition( workflow, schedule ); + response.Workflows.Add( workflowDef ); + } + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Returning {TaskCount} tasks and {WorkflowCount} workflows for agent {AgentId}.", + response.Tasks.Count.ToString( ), response.Workflows.Count.ToString( ), inner.ConnectionId ); + } + + return PayloadEncryptor.EncryptToEnvelope( response, connection.SharedKey, keyId ); + } + + /// Maps a and to a proto definition. + private static ScheduledTaskDefinition MapTaskDefinition( WerkrTask task, Schedule schedule ) { + ScheduledTaskDefinition def = new( ) { + TaskId = task.Id, + Name = task.Name, + ActionType = (int) task.ActionType, + Content = task.Content, + TimeoutMinutes = task.TimeoutMinutes ?? 30, + SyncIntervalMinutes = task.SyncIntervalMinutes, + Schedule = MapScheduleDefinition( schedule ), + SuccessCriteria = task.SuccessCriteria ?? string.Empty, + ActionSubType = task.ActionSubType ?? string.Empty, + ActionParametersJson = task.ActionParameters ?? string.Empty, + }; + + if (task.Arguments is { Length: > 0 }) { + def.Arguments.AddRange( task.Arguments ); + } + + return def; + } + + /// Maps a composite to a proto definition. + private static ScheduleDefinition MapScheduleDefinition( Schedule schedule ) { + ScheduleDefinition def = new( ) { + ScheduleId = schedule.DbSchedule.Id.ToString( ), + StopTaskAfterMinutes = schedule.DbSchedule.StopTaskAfterMinutes, + }; + + if (schedule.StartDateTime is not null) { + def.StartDate = schedule.StartDateTime.Date.ToString( "O" ); + def.StartTime = schedule.StartDateTime.Time.ToString( "O" ); + def.TimeZoneId = schedule.StartDateTime.TimeZone.Id; + } + + if (schedule.Expiration is not null) { + def.ExpirationDate = schedule.Expiration.Date.ToString( "O" ); + def.ExpirationTime = schedule.Expiration.Time.ToString( "O" ); + def.ExpirationTimeZoneId = schedule.Expiration.TimeZone.Id; + } + + if (schedule.DailyRecurrence is not null) { + def.Daily = new DailyRecurrenceDef { DayInterval = schedule.DailyRecurrence.DayInterval }; + } + + if (schedule.WeeklyRecurrence is not null) { + def.Weekly = new WeeklyRecurrenceDef { + WeekInterval = schedule.WeeklyRecurrence.WeekInterval, + RecurrenceDays = (int)schedule.WeeklyRecurrence.DaysOfWeek, + }; + } + + if (schedule.MonthlyRecurrence is not null) { + MonthlyRecurrenceDef monthly = new( ) { + MonthsOfYear = (int) schedule.MonthlyRecurrence.MonthsOfYear, + WeekNumber = schedule.MonthlyRecurrence.WeekNumber.HasValue + ? (int) schedule.MonthlyRecurrence.WeekNumber.Value : 0, + DaysOfWeek = schedule.MonthlyRecurrence.DaysOfWeek.HasValue + ? (int) schedule.MonthlyRecurrence.DaysOfWeek.Value : 0, + }; + + if (schedule.MonthlyRecurrence.DayNumbers is { Length: > 0 }) { + monthly.DayNumbers.AddRange( schedule.MonthlyRecurrence.DayNumbers ); + } + + def.Monthly = monthly; + } + + if (schedule.RepeatOptions is not null) { + def.Repeat = new RepeatOptionsDef { + IntervalMinutes = schedule.RepeatOptions.RepeatIntervalMinutes, + DurationMinutes = schedule.RepeatOptions.RepeatDurationMinutes, + }; + } + + // Holiday calendar metadata + def.HasHolidayCalendar = schedule.HolidayCalendar is not null; + def.HolidayCalendarMode = schedule.HolidayCalendarMode?.ToString( ) ?? string.Empty; + + return def; + } + + /// Maps a and to a proto definition. + private static ScheduledWorkflowDefinition MapWorkflowDefinition( Workflow workflow, Schedule schedule ) { + ScheduledWorkflowDefinition def = new( ) { + WorkflowId = workflow.Id, + Name = workflow.Name, + Schedule = MapScheduleDefinition( schedule ), + }; + + foreach (WorkflowStep step in workflow.Steps.OrderBy( s => s.Order )) { + ScheduledWorkflowStepDef stepDef = new( ) { + StepId = step.Id, + TaskId = step.TaskId, + Order = step.Order, + ControlStatement = (int) step.ControlStatement, + ConditionExpression = step.ConditionExpression ?? string.Empty, + MaxIterations = step.MaxIterations, + AgentConnectionIdOverride = step.AgentConnectionIdOverride?.ToString( ) ?? string.Empty, + DependencyMode = (int) step.DependencyMode, + }; + + // Add dependency step IDs + foreach (WorkflowStepDependency dep in step.Dependencies) { + stepDef.DependsOnStepIds.Add( dep.DependsOnStepId ); + } + + // Include task definition if available + if (step.Task is not null) { + WerkrTask stepTask = step.Task; + ScheduledTaskDefinition stepTaskDef = new( ) { + TaskId = stepTask.Id, + Name = stepTask.Name, + ActionType = (int) stepTask.ActionType, + Content = stepTask.Content, + TimeoutMinutes = stepTask.TimeoutMinutes ?? 30, + SyncIntervalMinutes = stepTask.SyncIntervalMinutes, + SuccessCriteria = stepTask.SuccessCriteria ?? string.Empty, + ActionSubType = stepTask.ActionSubType ?? string.Empty, + ActionParametersJson = stepTask.ActionParameters ?? string.Empty, + }; + + if (stepTask.Arguments is { Length: > 0 }) { + stepTaskDef.Arguments.AddRange( stepTask.Arguments ); + } + + stepDef.Task = stepTaskDef; + } + + def.Steps.Add( stepDef ); + } + + return def; + } + + /// + /// Returns holiday dates for multiple schedules in a single call. + /// Used by agents to bulk-fetch holiday data after schedule sync. + /// + public override async Task GetBulkScheduleHolidayDates( + EncryptedEnvelope request, + ServerCallContext context ) { + + RegisteredConnection connection = GetConnection( context ); + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + + GetBulkScheduleHolidayDatesRequest inner = PayloadEncryptor.DecryptFromEnvelope( + request, connection.SharedKey ); + + DateOnly startDate = DateOnly.Parse( inner.StartDate ); + DateOnly endDate = DateOnly.Parse( inner.EndDate ); + + GetBulkScheduleHolidayDatesResponse response = new( ); + + foreach (ScheduleHolidayDateQuery query in inner.Queries) { + Guid scheduleId = Guid.Parse( query.ScheduleId ); + + ScheduleHolidayCalendar? link = await holidayCalendarService.GetScheduleCalendarAsync( + scheduleId, context.CancellationToken ); + + if (link is null) { + continue; + } + + IReadOnlyList dates = await holidayDateService.GetDatesForRangeAsync( + link.HolidayCalendarId, startDate, endDate, context.CancellationToken ); + + ScheduleHolidayDateResult result = new( ) { + ScheduleId = scheduleId.ToString( ), + CalendarId = link.HolidayCalendarId.ToString( ), + Mode = link.Mode.ToString( ), + }; + + foreach (HolidayDate hd in dates) { + result.Dates.Add( new HolidayDateMessage { + Date = hd.Date.ToString( "O" ), + Name = hd.Name, + Year = hd.Year, + WindowStart = hd.WindowStart?.ToString( "O" ) ?? string.Empty, + WindowEnd = hd.WindowEnd?.ToString( "O" ) ?? string.Empty, + WindowTimeZoneId = hd.WindowTimeZoneId ?? string.Empty, + } ); + } + + response.Results.Add( result ); + } + + return PayloadEncryptor.EncryptToEnvelope( response, connection.SharedKey, keyId ); + } + + /// + /// Persists audit log entries submitted by an agent for suppressed/required holiday occurrences. + /// + public override async Task SubmitAuditLog( + EncryptedEnvelope request, + ServerCallContext context ) { + + RegisteredConnection connection = GetConnection( context ); + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + + SubmitAuditLogRequest inner = PayloadEncryptor.DecryptFromEnvelope( + request, connection.SharedKey ); + + Guid scheduleId = Guid.Parse( inner.ScheduleId ); + + // Look up calendar info for the schedule + ScheduleHolidayCalendar? link = await holidayCalendarService.GetScheduleCalendarAsync( + scheduleId, context.CancellationToken ); + + string calendarName = link?.Calendar?.Name ?? "Unknown"; + HolidayCalendarMode mode = link?.Mode ?? HolidayCalendarMode.Blocklist; + + List logs = [.. inner.Entries.Select( e => new ScheduleAuditLog { + ScheduleId = scheduleId, + OccurrenceUtcTime = DateTime.Parse( e.OccurrenceUtc ).ToUniversalTime( ), + CalendarName = calendarName, + HolidayName = e.HolidayName, + Mode = mode, + CreatedUtc = DateTime.UtcNow, + } )]; + + dbContext.ScheduleAuditLogs.AddRange( logs ); + _ = await dbContext.SaveChangesAsync( context.CancellationToken ); + + SubmitAuditLogResponse response = new( ) { + AcceptedCount = logs.Count, + }; + + return PayloadEncryptor.EncryptToEnvelope( response, connection.SharedKey, keyId ); + } + + private static RegisteredConnection GetConnection( ServerCallContext context ) { + return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection + ? connection + : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); + } +} diff --git a/src/Werkr.Api/Services/WorkflowExecutionGrpcService.cs b/src/Werkr.Api/Services/WorkflowExecutionGrpcService.cs new file mode 100644 index 0000000..637bbf2 --- /dev/null +++ b/src/Werkr.Api/Services/WorkflowExecutionGrpcService.cs @@ -0,0 +1,108 @@ +using Grpc.Core; + +using Microsoft.EntityFrameworkCore; + +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Workflows; +using Werkr.Data; +using Werkr.Data.Entities.Registration; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Api.Services; + +/// +/// gRPC service for handling Agent-initiated workflow execution requests. +/// When an Agent's schedule triggers a workflow, the Agent delegates execution +/// back to the Server because only the Server can orchestrate multi-agent workflows. +/// All RPCs use . +/// +/// Database context. +/// Workflow executor for DAG orchestration. +/// Logger instance. +public sealed class WorkflowExecutionGrpcService( + WerkrDbContext dbContext, + WorkflowExecutor workflowExecutor, + ILogger logger +) : WorkflowExecution.WorkflowExecutionBase { + + /// + /// Handles an Agent's request to run a workflow. Loads the workflow + /// and delegates to . + /// + public override async Task RequestWorkflowRun( + EncryptedEnvelope request, + ServerCallContext context ) { + + RegisteredConnection connection = GetConnection( context ); + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + + WorkflowRunGrpcRequest inner = PayloadEncryptor.DecryptFromEnvelope( + request, connection.SharedKey ); + + if (string.IsNullOrWhiteSpace( inner.ConnectionId )) { + throw new RpcException( new Status( StatusCode.InvalidArgument, "Connection ID is required." ) ); + } + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Agent {AgentId} requesting workflow {WorkflowId} execution.", + inner.ConnectionId, inner.WorkflowId.ToString( ) ); + } + + Workflow? workflow = await dbContext.Workflows + .Include( w => w.Steps ) + .ThenInclude( s => s.Task ) + .Include( w => w.Steps ) + .ThenInclude( s => s.Dependencies ) + .FirstOrDefaultAsync( w => w.Id == inner.WorkflowId, context.CancellationToken ); + + if (workflow is null) { + WorkflowRunGrpcResponse notFoundResponse = new( ) { + Accepted = false, + Message = $"Workflow with Id={inner.WorkflowId} not found.", + }; + return PayloadEncryptor.EncryptToEnvelope( notFoundResponse, connection.SharedKey, keyId ); + } + + if (!workflow.Enabled) { + WorkflowRunGrpcResponse disabledResponse = new( ) { + Accepted = false, + Message = $"Workflow '{workflow.Name}' is disabled.", + }; + return PayloadEncryptor.EncryptToEnvelope( disabledResponse, connection.SharedKey, keyId ); + } + + try { + WorkflowRun run = await workflowExecutor.ExecuteAsync( workflow, context.CancellationToken ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Workflow run {RunId} initiated for workflow {WorkflowId} '{WorkflowName}' by agent {AgentId}.", + run.Id.ToString( ), workflow.Id.ToString( ), workflow.Name, inner.ConnectionId ); + } + + WorkflowRunGrpcResponse response = new( ) { + Accepted = true, + WorkflowRunId = run.Id.ToString( ), + Message = $"Workflow run {run.Id} initiated.", + }; + return PayloadEncryptor.EncryptToEnvelope( response, connection.SharedKey, keyId ); + } catch (Exception ex) { + logger.LogError( ex, "Failed to execute workflow {WorkflowId} requested by agent {AgentId}.", + inner.WorkflowId.ToString( ), inner.ConnectionId ); + + WorkflowRunGrpcResponse errorResponse = new( ) { + Accepted = false, + Message = $"Failed to execute workflow: {ex.Message}", + }; + return PayloadEncryptor.EncryptToEnvelope( errorResponse, connection.SharedKey, keyId ); + } + } + + private static RegisteredConnection GetConnection( ServerCallContext context ) { + return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection + ? connection + : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); + } +} diff --git a/src/Werkr.Api/Werkr.Api.csproj b/src/Werkr.Api/Werkr.Api.csproj new file mode 100644 index 0000000..5435cad --- /dev/null +++ b/src/Werkr.Api/Werkr.Api.csproj @@ -0,0 +1,37 @@ + + + + Werkr.Api + Werkr.Api + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Werkr.Api/Werkr.Api.http b/src/Werkr.Api/Werkr.Api.http new file mode 100644 index 0000000..59ab38c --- /dev/null +++ b/src/Werkr.Api/Werkr.Api.http @@ -0,0 +1,6 @@ +@ApiService_HostAddress = http://localhost:5560 + +GET {{ApiService_HostAddress}}/weatherforecast/ +Accept: application/json + +### \ No newline at end of file diff --git a/src/Werkr.Api/appsettings.Development.json b/src/Werkr.Api/appsettings.Development.json new file mode 100644 index 0000000..5d6507a --- /dev/null +++ b/src/Werkr.Api/appsettings.Development.json @@ -0,0 +1,18 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Authentication": "Debug", + "System": "Information" + } + } + }, + "Jwt": { + "SigningKey": "werkr-dev-signing-key-do-not-use-in-production-min32chars!", + "Issuer": "werkr-api", + "Audience": "werkr" + } +} diff --git a/src/Werkr.Api/appsettings.json b/src/Werkr.Api/appsettings.json new file mode 100644 index 0000000..8f79fe2 --- /dev/null +++ b/src/Werkr.Api/appsettings.json @@ -0,0 +1,28 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "File", + "Args": { + "path": "logs/log-.txt", + "rollingInterval": "Day" + } + }, + { "Name": "OpenTelemetry" } + ], + "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ] + }, + "AllowedHosts": "*", + "AuditLog": { + "RetentionDays": 90 + } +} diff --git a/src/Werkr.Api/packages.lock.json b/src/Werkr.Api/packages.lock.json new file mode 100644 index 0000000..e6dd24b --- /dev/null +++ b/src/Werkr.Api/packages.lock.json @@ -0,0 +1,554 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Grpc.AspNetCore": { + "type": "Direct", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==", + "dependencies": { + "Google.Protobuf": "3.31.1", + "Grpc.AspNetCore.Server.ClientFactory": "2.76.0", + "Grpc.Tools": "2.76.0" + } + }, + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.78.0, )", + "resolved": "2.78.0", + "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" + }, + "Microsoft.AspNetCore.Authentication.JwtBearer": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "TBDs8e9y2vJHp14EwNfnIZUNrm6siw8PAAU5laOrYFuGgRxx8oCdxZyfTgp1Oy/icUk9h/XtpYBHPnXIG0f2/g==", + "dependencies": { + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "SAvSrKDgnY5GDjDAngOXxPhUvEKlTU/0zIq8zidqHvh/xnZBPs0Vc4LqwyvnmnafNnyUaivtRABz4K4wodXfSg==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Direct", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Serilog.AspNetCore": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "Direct", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Sinks.OpenTelemetry": { + "type": "Direct", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", + "dependencies": { + "Google.Protobuf": "3.30.1", + "Grpc.Net.Client": "2.70.0", + "Serilog": "4.2.0" + } + }, + "Grpc.AspNetCore.Server": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0" + } + }, + "Grpc.AspNetCore.Server.ClientFactory": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==", + "dependencies": { + "Grpc.AspNetCore.Server": "2.76.0", + "Grpc.Net.ClientFactory": "2.76.0" + } + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==", + "dependencies": { + "Grpc.Core.Api": "2.76.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==" + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==" + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==" + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", + "dependencies": { + "Microsoft.Extensions.Telemetry": "10.3.0" + } + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.ServiceDiscovery.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==" + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.0.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "8.0.1" + } + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==" + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", + "dependencies": { + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", + "dependencies": { + "OpenTelemetry.Api": "1.15.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.core": { + "type": "Project", + "dependencies": { + "Grpc.Net.Client": "[2.76.0, )", + "System.Security.Cryptography.ProtectedData": "[10.0.3, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )" + } + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "werkr.servicedefaults": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0" + } + }, + "Grpc.Net.ClientFactory": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==", + "dependencies": { + "Grpc.Net.Client": "2.76.0" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.3.0", + "Microsoft.Extensions.Resilience": "10.3.0" + } + }, + "Microsoft.Extensions.ServiceDiscovery": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", + "dependencies": { + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.0.1", + "contentHash": "GJw3bYkWpOgvN3tJo5X4lYUeIFA2HD293FPUhKmp7qxS+g5ywAb34Dnd3cDAFLkcMohy5XTpoaZ4uAHuw0uSPQ==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.0.1", + "Microsoft.IdentityModel.Tokens": "8.0.1" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" + } + } + } +} \ No newline at end of file diff --git a/src/Werkr.AppHost/AppHost.cs b/src/Werkr.AppHost/AppHost.cs new file mode 100644 index 0000000..09f568f --- /dev/null +++ b/src/Werkr.AppHost/AppHost.cs @@ -0,0 +1,42 @@ + +namespace Werkr.AppHost; + +/// Aspire AppHost orchestrator entry point. +public class Program { + /// Main entry point. + /// Command-line arguments. + public static void Main( string[] args ) { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder( args ); + + // Infrastructure — two separate databases + IResourceBuilder postgres = builder.AddPostgres( "postgres" ) + .WithDataVolume( ); + + IResourceBuilder werkrDb = postgres.AddDatabase( "werkrdb" ); + IResourceBuilder werkrIdentityDb = postgres.AddDatabase( "werkridentitydb" ); + + // Agent + IResourceBuilder agent = builder.AddProject( "agent" ) + .WithHttpHealthCheck( "/health" ) + .WaitFor( werkrDb ); + + // API Service — owns the application database + IResourceBuilder apiService = builder.AddProject( "api" ) + .WithHttpHealthCheck( "/health" ) + .WithReference( werkrDb ) + .WaitFor( werkrDb ); + + // Server (Blazor UI) — owns the identity database + _ = builder.AddProject( "server" ) + .WithExternalHttpEndpoints( ) + .WithHttpHealthCheck( "/health" ) + .WithReference( apiService ) + .WithReference( agent ) + .WithReference( werkrIdentityDb ) + .WaitFor( apiService ) + .WaitFor( agent ) + .WaitFor( werkrIdentityDb ); + + builder.Build( ).Run( ); + } +} diff --git a/src/Werkr.AppHost/Werkr.AppHost.csproj b/src/Werkr.AppHost/Werkr.AppHost.csproj new file mode 100644 index 0000000..261a9f8 --- /dev/null +++ b/src/Werkr.AppHost/Werkr.AppHost.csproj @@ -0,0 +1,18 @@ + + + + Exe + cdb4ebac-414e-4273-abb3-0f4922f71cf3 + + + + + + + + + + + + + diff --git a/src/Werkr.AppHost/appsettings.Development.json b/src/Werkr.AppHost/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/src/Werkr.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Werkr.AppHost/appsettings.json b/src/Werkr.AppHost/appsettings.json new file mode 100644 index 0000000..2185f95 --- /dev/null +++ b/src/Werkr.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/Werkr.AppHost/packages.lock.json b/src/Werkr.AppHost/packages.lock.json new file mode 100644 index 0000000..82710cd --- /dev/null +++ b/src/Werkr.AppHost/packages.lock.json @@ -0,0 +1,656 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Aspire.Dashboard.Sdk.win-arm64": { + "type": "Direct", + "requested": "[13.1.2, )", + "resolved": "13.1.2", + "contentHash": "Sltiw6We9iia/isJItRmZ2ATfNJFPmEYiXFT5lzDL6Iu7vc/p/dge0SbUshY1SCLxWilwVCe0uv9WQyzoN+nWg==" + }, + "Aspire.Hosting.AppHost": { + "type": "Direct", + "requested": "[13.1.2, )", + "resolved": "13.1.2", + "contentHash": "w4VCvBM7aHEN6xogV+G4ie4SxTTeLDuZEf0tze6kqYT4tf9jfTh+SPO00vLnZLS4gX+rUfUOTGuO56jwzIaawA==", + "dependencies": { + "AspNetCore.HealthChecks.Uris": "9.0.0", + "Aspire.Hosting": "13.1.2", + "Google.Protobuf": "3.33.0", + "Grpc.AspNetCore": "2.71.0", + "Grpc.Net.ClientFactory": "2.71.0", + "Grpc.Tools": "2.72.0", + "Humanizer.Core": "2.14.1", + "JsonPatch.Net": "3.3.0", + "KubernetesClient": "18.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.1", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.1", + "Microsoft.Extensions.Hosting": "10.0.1", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.1", + "Microsoft.Extensions.Http": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1", + "Newtonsoft.Json": "13.0.4", + "Polly.Core": "8.6.4", + "Semver": "3.0.0", + "StreamJsonRpc": "2.22.23", + "System.IO.Hashing": "9.0.10" + } + }, + "Aspire.Hosting.Orchestration.win-arm64": { + "type": "Direct", + "requested": "[13.1.2, )", + "resolved": "13.1.2", + "contentHash": "khjFSK2xbPR1S5sLG4XcDqyi/7TCQAyL8xkAmmFJt/aG0yFvVeqXXGokutz/YEEit0QpAeyuiPjWTpKIX70vPg==" + }, + "Aspire.Hosting.PostgreSQL": { + "type": "Direct", + "requested": "[13.1.2, )", + "resolved": "13.1.2", + "contentHash": "Ixd060h2SzX4xEPmdmH5E6lRIbFQv5QDYy6JzF/oA7kDwOxmqIon5GCSoHetnpInO9VBlZ0P576uqY/yJWgtdA==", + "dependencies": { + "AspNetCore.HealthChecks.NpgSql": "9.0.0", + "AspNetCore.HealthChecks.Uris": "9.0.0", + "Aspire.Hosting": "13.1.2", + "Google.Protobuf": "3.33.0", + "Grpc.AspNetCore": "2.71.0", + "Grpc.Net.ClientFactory": "2.71.0", + "Grpc.Tools": "2.72.0", + "Humanizer.Core": "2.14.1", + "JsonPatch.Net": "3.3.0", + "KubernetesClient": "18.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.2", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.22", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.1", + "Microsoft.Extensions.Hosting": "8.0.1", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.1", + "Microsoft.Extensions.Http": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0", + "Newtonsoft.Json": "13.0.4", + "Polly.Core": "8.6.4", + "Semver": "3.0.0", + "StreamJsonRpc": "2.22.23", + "System.IO.Hashing": "9.0.10" + } + }, + "Aspire.Hosting": { + "type": "Transitive", + "resolved": "13.1.2", + "contentHash": "q7TOaDHqR8eFsEUaGuA9Xm3aDOZioxYXu0Dx1AfmfXbR0aPjvCiRDv/ebaq7eR18L6bm+az6NqZdNczi5BPu9Q==", + "dependencies": { + "AspNetCore.HealthChecks.Uris": "9.0.0", + "Google.Protobuf": "3.33.0", + "Grpc.AspNetCore": "2.71.0", + "Grpc.Net.ClientFactory": "2.71.0", + "Grpc.Tools": "2.72.0", + "Humanizer.Core": "2.14.1", + "JsonPatch.Net": "3.3.0", + "KubernetesClient": "18.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.2", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.22", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.1", + "Microsoft.Extensions.Hosting": "8.0.1", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.1", + "Microsoft.Extensions.Http": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0", + "Newtonsoft.Json": "13.0.4", + "Polly.Core": "8.6.4", + "Semver": "3.0.0", + "StreamJsonRpc": "2.22.23", + "System.IO.Hashing": "9.0.10" + } + }, + "AspNetCore.HealthChecks.NpgSql": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "Npgsql": "8.0.3" + } + }, + "AspNetCore.HealthChecks.Uris": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "XYdNlA437KeF8p9qOpZFyNqAN+c0FXt/JjTvzH/Qans0q0O3pPE8KPnn39ucQQjR/Roum1vLTP3kXiUs8VHyuA==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "Microsoft.Extensions.Http": "8.0.0" + } + }, + "Fractions": { + "type": "Transitive", + "resolved": "7.3.0", + "contentHash": "2bETFWLBc8b7Ut2SVi+bxhGVwiSpknHYGBh2PADyGWONLkTxT7bKyDRhF8ao+XUv90tq8Fl7GTPxSI5bacIRJw==" + }, + "Grpc.AspNetCore.Server": { + "type": "Transitive", + "resolved": "2.71.0", + "contentHash": "kv+9YVB6MqDYWIcstXvWrT7Xc1si/sfINzzSxvQfjC3aei+92gXDUXCH/Q+TEvi4QSICRqu92BYcrXUBW7cuOw==", + "dependencies": { + "Grpc.Net.Common": "2.71.0" + } + }, + "Grpc.AspNetCore.Server.ClientFactory": { + "type": "Transitive", + "resolved": "2.71.0", + "contentHash": "AHvMxoC+esO1e/nOYBjxvn0WDHAfglcVBjtkBy6ohgnV+PzkF8UdkPHE02xnyPFaSokWGZKnWzjgd00x6EZpyQ==", + "dependencies": { + "Grpc.AspNetCore.Server": "2.71.0", + "Grpc.Net.ClientFactory": "2.71.0" + } + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.71.0", + "contentHash": "QquqUC37yxsDzd1QaDRsH2+uuznWPTS8CVE2Yzwl3CvU4geTNkolQXoVN812M2IwT6zpv3jsZRc9ExJFNFslTg==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.71.0", + "contentHash": "v0c8R97TwRYwNXlC8GyRXwYTCNufpDfUtj9la+wUrZFzVWkFJuNAltU+c0yI3zu0jl54k7en6u2WKgZgd57r2Q==", + "dependencies": { + "Grpc.Core.Api": "2.71.0" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Json.More.Net": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "qtwsyAsL55y2vB2/sK4Pjg3ZyVzD5KKSpV3lOAMHlnjFfsjQ/86eHJfQT9aV1YysVXzF4+xyHOZbh7Iu3YQ7Lg==" + }, + "JsonPatch.Net": { + "type": "Transitive", + "resolved": "3.3.0", + "contentHash": "GIcMMDtzfzVfIpQgey8w7dhzcw6jG5nD4DDAdQCTmHfblkCvN7mI8K03to8YyUhKMl4PTR6D6nLSvWmyOGFNTg==", + "dependencies": { + "JsonPointer.Net": "5.2.0" + } + }, + "JsonPointer.Net": { + "type": "Transitive", + "resolved": "5.2.0", + "contentHash": "qe1F7Tr/p4mgwLPU9P60MbYkp+xnL2uCPnWXGgzfR/AZCunAZIC0RZ32dLGJJEhSuLEfm0YF/1R3u5C7mEVq+w==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Json.More.Net": "2.1.0" + } + }, + "KubernetesClient": { + "type": "Transitive", + "resolved": "18.0.5", + "contentHash": "xkttIbnGNibYwAyZ0sqeQle2w90bfaJrkF8BaURWHfSMKPbHwys9t/wq1XmT64eA4WRVXLENYlXtqmWlEstG6A==", + "dependencies": { + "Fractions": "7.3.0", + "YamlDotNet": "16.3.0" + } + }, + "MessagePack": { + "type": "Transitive", + "resolved": "2.5.192", + "contentHash": "Jtle5MaFeIFkdXtxQeL9Tu2Y3HsAQGoSntOzrn6Br/jrl6c8QmG22GEioT5HBtZJR0zw0s46OnKU8ei2M3QifA==", + "dependencies": { + "MessagePack.Annotations": "2.5.192", + "Microsoft.NET.StringTools": "17.6.3" + } + }, + "MessagePack.Annotations": { + "type": "Transitive", + "resolved": "2.5.192", + "contentHash": "jaJuwcgovWIZ8Zysdyf3b7b34/BrADw4v82GaEZymUhDd3ScMPrYd/cttekeDteJJPXseJxp04yTIcxiVUjTWg==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "njoRekyMIK+smav8B6KL2YgIfUtlsRNuT7wvurpLW+m/hoRKVnoELk2YxnUnWRGScCd1rukLMxShwLqEOKowDg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Lp4CZIuTVXtlvkAnTq6QvMSW7+H62gX2cU2vdFxHQUxvrWTpi7LwYI3X+YAyIS0r12/p7gaosco7efIxL4yFNw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "s5cxcdtIig66YT3J+7iHflMuorznK8kXuwBBPHMp4KImx5ZGE3FRa1Nj9fI/xMwFV+KzUMjqZ2MhOedPH8LiBQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "csD8Eps3HQ3yc0x6NhgTV+aIFKSs3qVlVCtFnMHz/JOjnv7eEj/qSXKXiK9LzBzB1qSfAVqFnB5iaX2nUmagIQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "N/6GiwiZFCBFZDk3vg1PhHW3zMqqu5WWpmeZAA9VTXv7Q8pr8NZR/EQsH0DjzqydDksJtY6EQBsu81d5okQOlA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Physical": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "ULEJ0nkaW90JYJGkFujPcJtADXcJpXiSOLbokPcWJZ8iDbtDINifEYAUVqZVr81IDNTrRFul6O8RolOKOsgFPg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Json": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Physical": "10.0.1" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "oIy8fQxxbUsSrrOvgBqlVgOeCtDmrcynnTG+FQufcUWBrwyPfwlUkCDB2vaiBeYPyT+20u9/HeuHeBf+H4F/8g==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "YaocqxscJLxLit0F5yq2XyB+9C7rSRfeTL7MJIl7XwaOoUO3i0EqfO2kmtjiRduYWw7yjcSINEApYZbzjau2gQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "QMoMrkNpnQym5mpfdxfxpRDuqLpsOuztguFvzH9p+Ex+do+uLFoi7UkAsBO4e9/tNR3eMFraFf2fOAi2cp3jjA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "LMQ1mW8YvfupNwoWXk+IOtjYTUllUIrETrWslKOsV66RvD96WaePcCcuF7SmB6fcTOuJFsSu/zILIUJGM+fM/Q==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.1", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "cebN2lQD6S7XCQ7DFKnXcBHxSfqWr38H9YliEluJIDRTJ99Q9V6gBMXBYx03kh6YJAzfN3JoEcsikpEWnC6VwA==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "+b3DligYSZuoWltU5YdbMpIEUHNZPgPrzWfNiIuDkMdqOl93UxYB5KzS3lgpRfTXJhTNpo/CZ8w/sTkDTPDdxQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "4bxzGXIzZnz0Bf7czQ72jGvpOqJsRW/44PS7YLFXTTnu6cNcPvmSREDvBoH0ZVP2hAbMfL4sUoCUn54k70jPWw==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "49dFvGJjLSwGn76eHnP1gBvCJkL8HRYpCrG0DCvsP6wRpEQRLN2Fq8rTxbP+6jS7jmYKCnSVO5C65v4mT3rzeA==" + }, + "Microsoft.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "0jjfjQSOFZlHhwOoIQw0WyzxtkDMYdnPY3iFrOLasxYq/5J4FDt1HWT8TktBclOVjFY1FOOkoOc99X7AhbqSIw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.1", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.1", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.1", + "Microsoft.Extensions.Configuration.Json": "10.0.1", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.1", + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Physical": "10.0.1", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Configuration": "10.0.1", + "Microsoft.Extensions.Logging.Console": "10.0.1", + "Microsoft.Extensions.Logging.Debug": "10.0.1", + "Microsoft.Extensions.Logging.EventLog": "10.0.1", + "Microsoft.Extensions.Logging.EventSource": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "ZXJup9ReE1Ot3M8jqcw1b/lnc8USxyYS3cyLsssU39u04TES9JNGviWUGIvP3K7mMU3TF7kQl2aS0SmVwegflw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "9ItMpMLFZFJFqCuHLLbR3LiA4ahA8dMtYuXpXl2YamSDWZhYS9BruPprkftY0tYi2bQ0slNrixdFm+4kpz1g5w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Zg8LLnfZs5o2RCHD/+9NfDtJ40swauemwCa7sI8gQoAye/UJHRZNpCtC7a5XE7l9Z7mdI8iMWnLZ6m7Q6S3jLg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "38Q8sEHwQ/+wVO/mwQBa0fcdHbezFpusHE+vBw/dSr6Fq/kzZm3H/NQX511Jki/R3FHd64IY559gdlHZQtYeEA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Configuration": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "Zp9MM+jFCa7oktIug62V9eNygpkf+6kFVatF+UC/ODeUwIr5givYKy8fYSSI9sWdxqDqv63y1x0mm2VjOl8GOw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "System.Diagnostics.EventLog": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "WnFvZP+Y+lfeNFKPK/+mBpaCC7EeBDlobrQOqnP7rrw/+vE7yu8Rjczum1xbC0F/8cAHafog84DMp9200akMNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "G6VVwywpJI4XIobetGHwg7wDOYC2L2XBYdtskxLaKF/Ynb5QBwLl7Q//wxAR2aVCLkMpoQrjSP9VoORkyddsNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "pL78/Im7O3WmxHzlKUsWTYchKL881udU7E26gCD3T0+/tPhWVfjPwMzfN/MRKU7aoFYcOiqcG2k1QTlH5woWow==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "DO8XrJkp5x4PddDuc/CH37yDBCs9BYN6ijlKyR3vMb55BP1Vwh90vOX8bNfnKxr5B2qEI3D8bvbY1fFbDveDHQ==" + }, + "Microsoft.NET.StringTools": { + "type": "Transitive", + "resolved": "17.6.3", + "contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA==" + }, + "Microsoft.VisualStudio.Threading.Only": { + "type": "Transitive", + "resolved": "17.13.61", + "contentHash": "vl5a2URJYCO5m+aZZtNlAXAMz28e2pUotRuoHD7RnCWOCeoyd8hWp5ZBaLNYq4iEj2oeJx5ZxiSboAjVmB20Qg==", + "dependencies": { + "Microsoft.VisualStudio.Validation": "17.8.8" + } + }, + "Microsoft.VisualStudio.Validation": { + "type": "Transitive", + "resolved": "17.8.8", + "contentHash": "rWXThIpyQd4YIXghNkiv2+VLvzS+MCMKVRDR0GAMlflsdo+YcAN2g2r5U1Ah98OFjQMRexTFtXQQ2LkajxZi3g==" + }, + "Nerdbank.Streams": { + "type": "Transitive", + "resolved": "2.12.87", + "contentHash": "oDKOeKZ865I5X8qmU3IXMyrAnssYEiYWTobPGdrqubN3RtTzEHIv+D6fwhdcfrdhPJzHjCkK/ORztR/IsnmA6g==", + "dependencies": { + "Microsoft.VisualStudio.Threading.Only": "17.13.61", + "Microsoft.VisualStudio.Validation": "17.8.8" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.6.4", + "contentHash": "4AWqYnQ2TME0E+Mzovt1Uu+VyvpR84ymUldMcPw7Mbj799Phaag14CKrMtlJGx5jsvYP+S3oR1QmysgmXoD5cw==" + }, + "Semver": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "5.0.1" + } + }, + "StreamJsonRpc": { + "type": "Transitive", + "resolved": "2.22.23", + "contentHash": "Ahq6uUFPnU9alny5h4agyX74th3PRq3NQCRNaDOqWcx20WT06mH/wENSk5IbHDc8BmfreQVEIBx5IXLBbsLFIA==", + "dependencies": { + "MessagePack": "2.5.192", + "Microsoft.VisualStudio.Threading.Only": "17.13.61", + "Microsoft.VisualStudio.Validation": "17.8.8", + "Nerdbank.Streams": "2.12.87", + "Newtonsoft.Json": "13.0.3" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "9gv5z71xaWWmcGEs4bXdreIhKp2kYLK2fvPK5gQkgnWMYvZ8ieaxKofDjxL3scZiEYfi/yW2nJTiKV2awcWEdA==" + }, + "YamlDotNet": { + "type": "Transitive", + "resolved": "16.3.0", + "contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA==" + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.33.0", + "contentHash": "+kIa03YipuiSDeRuZwcDcXS1xBQAFeGLIjuLbgJr2i+TlwBPYAqdnQZJ2SDVzIgDyy+q+n/400WyWyrJ5ZqCgQ==" + }, + "Grpc.AspNetCore": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.71.0", + "contentHash": "B4wAbNtAuHNiHAMxLFWL74wUElzNOOboFnypalqpX76piCOGz/w5FpilbVVYGboI4Qgl4ZmZsvDZ1zLwHNsjnw==", + "dependencies": { + "Google.Protobuf": "3.30.2", + "Grpc.AspNetCore.Server.ClientFactory": "2.71.0", + "Grpc.Tools": "2.71.0" + } + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.71.0", + "contentHash": "U1vr20r5ngoT9nlb7wejF28EKN+taMhJsV9XtK9MkiepTZwnKxxiarriiMfCHuDAfPUm9XUjFMn/RIuJ4YY61w==", + "dependencies": { + "Grpc.Net.Common": "2.71.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0" + } + }, + "Grpc.Net.ClientFactory": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.71.0", + "contentHash": "8oPLwQLPo86fmcf9ghjCDyNsSWhtHc3CXa/AqwF8Su/pG7qAoeWWtbymsZhoNvCV9Zjzb6BDcIPKXLYt+O175g==", + "dependencies": { + "Grpc.Net.Client": "2.71.0", + "Microsoft.Extensions.Http": "6.0.0" + } + }, + "Grpc.Tools": { + "type": "CentralTransitive", + "requested": "[2.78.0, )", + "resolved": "2.72.0", + "contentHash": "BCiuQ03EYjLHCo9hqZmY5barsz5vvcz/+/ICt5wCbukaePHZmMPDGelKlkxWx3q+f5xOMNHa9zXQ2N6rQZ4B+w==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.1", + "contentHash": "kPlU11hql+L9RjrN2N9/0GcRcRcZrNFlLLjadasFWeBORT6pL6OE+RYRk90GGCyVGSxTK+e1/f3dsMj5zpFFiQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.1" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.1", + "contentHash": "0zW3eYBJlRctmgqk5s0kFIi5o5y2g80mvGCD8bkYxREPQlKUnr0ndU/Sop+UDIhyWN0fIi4RW63vo7BKTi7ncA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.1", + "contentHash": "qmoQkVZcbm4/gFpted3W3Y+1kTATZTcUhV3mRkbtpfBXlxWCHwh/2oMffVcCruaGOfJuEnyAsGyaSUouSdECOw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.1", + "contentHash": "YkmyiPIWAXVb+lPIrM0LE5bbtLOJkCiRTFiHpkVOvhI7uTvCfoOHLEN0LcsY56GpSD7NqX3gJNpsaDe87/B3zg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.1", + "contentHash": "VqfTvbX9C6BA0VeIlpzPlljnNsXxiI5CdUHb9ksWERH94WQ6ft3oLGUAa4xKcDGu4xF+rIZ8wj7IOAd6/q7vGw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1" + } + } + } + } +} \ No newline at end of file diff --git a/src/Werkr.Common.Configuration/AgentSettings.cs b/src/Werkr.Common.Configuration/AgentSettings.cs new file mode 100644 index 0000000..4495d45 --- /dev/null +++ b/src/Werkr.Common.Configuration/AgentSettings.cs @@ -0,0 +1,25 @@ +namespace Werkr.Common.Configuration; + +/// +/// Configuration DTO for agent-specific settings. +/// Compatible with the options pattern (IOptions<T>). +/// +public sealed class AgentSettings { + /// Configuration section name. + public const string SectionName = "Agent"; + + /// Display name for this agent instance. + public string Name { get; set; } = "Default Agent"; + + /// Port the agent gRPC service listens on. + public int GrpcPort { get; set; } = 5100; + + /// Whether the PowerShell operator is enabled. + public bool EnablePowerShell { get; set; } = true; + + /// Whether the system shell operator is enabled. + public bool EnableSystemShell { get; set; } = true; + + /// PowerShell-specific settings. + public PowerShellSettings PowerShell { get; set; } = new( ); +} diff --git a/src/Werkr.Common.Configuration/JobOutputOptions.cs b/src/Werkr.Common.Configuration/JobOutputOptions.cs new file mode 100644 index 0000000..9dc2b55 --- /dev/null +++ b/src/Werkr.Common.Configuration/JobOutputOptions.cs @@ -0,0 +1,21 @@ +namespace Werkr.Common.Configuration; + +/// +/// Configuration for file-based job output storage. +/// Compatible with the options pattern (IOptions<T>). +/// +public sealed class JobOutputOptions { + /// Configuration section name. + public const string SectionName = "JobOutput"; + + /// + /// Directory where job output log files are stored. + /// Defaults to job-output relative to the content root. + /// + public string OutputDirectory { get; set; } = "job-output"; + + /// + /// Maximum number of characters to store in the database as a tail preview. + /// + public int TailPreviewLength { get; set; } = 2000; +} diff --git a/src/Werkr.Common.Configuration/PowerShellSettings.cs b/src/Werkr.Common.Configuration/PowerShellSettings.cs new file mode 100644 index 0000000..b9958f3 --- /dev/null +++ b/src/Werkr.Common.Configuration/PowerShellSettings.cs @@ -0,0 +1,14 @@ +namespace Werkr.Common.Configuration; + +/// +/// Configuration DTO for PowerShell operator settings. +/// Nested under Agent:PowerShell in configuration. +/// +public sealed class PowerShellSettings { + /// + /// Buffer width in columns for the custom PSHost. Controls how + /// Format-Table, Format-List, and other formatting + /// cmdlets wrap output. Default is 150. + /// + public int BufferWidth { get; set; } = 150; +} diff --git a/src/Werkr.Common.Configuration/ServerSettings.cs b/src/Werkr.Common.Configuration/ServerSettings.cs new file mode 100644 index 0000000..071ae77 --- /dev/null +++ b/src/Werkr.Common.Configuration/ServerSettings.cs @@ -0,0 +1,16 @@ +namespace Werkr.Common.Configuration; + +/// +/// Configuration DTO for server-specific settings. +/// Compatible with the options pattern (IOptions<T>). +/// +public sealed class ServerSettings { + /// Configuration section name. + public const string SectionName = "Server"; + + /// Display name for this server instance. + public string Name { get; set; } = "Werkr Server"; + + /// Whether new agent registration is allowed. + public bool AllowRegistration { get; set; } = true; +} diff --git a/src/Werkr.Common.Configuration/UiSettings.cs b/src/Werkr.Common.Configuration/UiSettings.cs new file mode 100644 index 0000000..0c4c888 --- /dev/null +++ b/src/Werkr.Common.Configuration/UiSettings.cs @@ -0,0 +1,16 @@ +namespace Werkr.Common.Configuration; + +/// +/// Configuration DTO for UI-specific settings such as polling intervals. +/// Compatible with the options pattern (IOptions<T>). +/// +public sealed class UiSettings { + /// Configuration section name. + public const string SectionName = "Ui"; + + /// Default polling interval in seconds for dashboard and list views. + public int PollingIntervalSeconds { get; set; } = 30; + + /// Default polling interval in seconds for workflow run detail views. + public int RunDetailPollingIntervalSeconds { get; set; } = 15; +} diff --git a/src/Werkr.Common.Configuration/Werkr.Common.Configuration.csproj b/src/Werkr.Common.Configuration/Werkr.Common.Configuration.csproj new file mode 100644 index 0000000..62dbaf3 --- /dev/null +++ b/src/Werkr.Common.Configuration/Werkr.Common.Configuration.csproj @@ -0,0 +1,12 @@ + + + + netstandard2.0 + latest + enable + false + Werkr.Common.Configuration + true + + + diff --git a/src/Werkr.Common.Configuration/WerkrConfiguration.cs b/src/Werkr.Common.Configuration/WerkrConfiguration.cs new file mode 100644 index 0000000..5999b57 --- /dev/null +++ b/src/Werkr.Common.Configuration/WerkrConfiguration.cs @@ -0,0 +1,25 @@ +namespace Werkr.Common.Configuration; + +/// +/// Central configuration settings for the Werkr application. +/// Compatible with the options pattern (IOptions<T>). +/// +public sealed class WerkrConfiguration { + /// Configuration section name. + public const string SectionName = "Werkr"; + + /// Connection string for the primary database. + public string ConnectionString { get; set; } = string.Empty; + + /// Default RSA key size in bits. + public int DefaultKeySize { get; set; } = 4096; + + /// The Server's gRPC endpoint URL (embedded in registration bundles). + public string ServerUrl { get; set; } = string.Empty; + + /// The Agent's gRPC endpoint URL (sent during registration). + public string AgentUrl { get; set; } = string.Empty; + + /// Default timeout for command execution in minutes. + public int DefaultCommandTimeoutMinutes { get; set; } = 30; +} diff --git a/src/Werkr.Common.Configuration/packages.lock.json b/src/Werkr.Common.Configuration/packages.lock.json new file mode 100644 index 0000000..7f5b5f6 --- /dev/null +++ b/src/Werkr.Common.Configuration/packages.lock.json @@ -0,0 +1,21 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.0": { + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + } + } + } +} \ No newline at end of file diff --git a/src/Werkr.Common/Auth/JwtValidationConfigurator.cs b/src/Werkr.Common/Auth/JwtValidationConfigurator.cs new file mode 100644 index 0000000..007d20c --- /dev/null +++ b/src/Werkr.Common/Auth/JwtValidationConfigurator.cs @@ -0,0 +1,35 @@ +using System.Text; + +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Werkr.Common.Auth; + +/// +/// Shared JWT validation configuration used by both the API (JWT Bearer validation) +/// and the Server (if needed for token introspection). Reads signing key, issuer, +/// and audience from Jwt:* configuration keys (Decision A6). +/// +public static class JwtValidationConfigurator { + /// + /// Returns built from the given configuration. + /// + /// The application configuration. + /// Configured . + public static TokenValidationParameters GetParameters( IConfiguration config ) { + string signingKey = config["Jwt:SigningKey"] + ?? throw new InvalidOperationException( + "JWT signing key is not configured. Set 'Jwt:SigningKey' in appsettings.json or environment variables." ); + + return new TokenValidationParameters { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes( signingKey ) ), + ValidateIssuer = true, + ValidIssuer = config["Jwt:Issuer"] ?? "werkr-api", + ValidateAudience = true, + ValidAudience = config["Jwt:Audience"] ?? "werkr", + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes( 1 ), + }; + } +} diff --git a/src/Werkr.Common/Auth/Permission.cs b/src/Werkr.Common/Auth/Permission.cs new file mode 100644 index 0000000..1630acb --- /dev/null +++ b/src/Werkr.Common/Auth/Permission.cs @@ -0,0 +1,25 @@ +namespace Werkr.Common.Auth; + +/// +/// Coarse-grained permission types for the Werkr application. +/// Designed for expansion to fine-grained permissions in a future phase. +/// +public enum Permission { + /// Can create new entities (schedules, tasks, workflows). + Create = 0, + + /// Can read/view entities and data. + Read = 1, + + /// Can modify existing entities. + Update = 2, + + /// Can remove entities. + Delete = 3, + + /// Can execute tasks, run workflows, and manage agent operations. + Execute = 4, + + /// Full administrative access (user management, settings, roles/permissions). + Admin = 5, +} diff --git a/src/Werkr.Common/Auth/PermissionPolicyExtensions.cs b/src/Werkr.Common/Auth/PermissionPolicyExtensions.cs new file mode 100644 index 0000000..f3477b1 --- /dev/null +++ b/src/Werkr.Common/Auth/PermissionPolicyExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Werkr.Common.Auth; + +/// +/// Registers the 6 standard Werkr permission-based authorization policies. +/// Each policy wraps a for a specific . +/// Called by both the API and Server during startup. +/// +public static class PermissionPolicyExtensions { + /// + /// Adds the standard Werkr permission policies to the authorization options. + /// Call from builder.Services.AddAuthorization(). + /// + public static AuthorizationOptions AddWerkrPermissionPolicies( this AuthorizationOptions options ) { + options.AddPolicy( Policies.CanCreate, policy => + policy.Requirements.Add( new PermissionRequirement( Permission.Create ) ) ); + options.AddPolicy( Policies.CanRead, policy => + policy.Requirements.Add( new PermissionRequirement( Permission.Read ) ) ); + options.AddPolicy( Policies.CanUpdate, policy => + policy.Requirements.Add( new PermissionRequirement( Permission.Update ) ) ); + options.AddPolicy( Policies.CanDelete, policy => + policy.Requirements.Add( new PermissionRequirement( Permission.Delete ) ) ); + options.AddPolicy( Policies.CanExecute, policy => + policy.Requirements.Add( new PermissionRequirement( Permission.Execute ) ) ); + options.AddPolicy( Policies.IsAdmin, policy => + policy.Requirements.Add( new PermissionRequirement( Permission.Admin ) ) ); + + return options; + } +} diff --git a/src/Werkr.Common/Auth/PermissionRequirement.cs b/src/Werkr.Common/Auth/PermissionRequirement.cs new file mode 100644 index 0000000..1789b51 --- /dev/null +++ b/src/Werkr.Common/Auth/PermissionRequirement.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Werkr.Common.Auth; + +/// +/// Authorization requirement that demands a specific . +/// Shared across API (claims-based handler) and Server (DB-backed handler). +/// +public sealed class PermissionRequirement( Permission permission ) + : IAuthorizationRequirement { + + /// The permission that the user must have. + public Permission Permission { get; } = permission; +} diff --git a/src/Werkr.Common/Auth/Policies.cs b/src/Werkr.Common/Auth/Policies.cs new file mode 100644 index 0000000..6d56a3f --- /dev/null +++ b/src/Werkr.Common/Auth/Policies.cs @@ -0,0 +1,25 @@ +namespace Werkr.Common.Auth; + +/// +/// Constants for permission-based authorization policy names. +/// Use with [Authorize( Policy = Policies.CanRead )] on Blazor pages and endpoints. +/// +public static class Policies { + /// User can create entities (schedules, tasks, workflows). + public const string CanCreate = "CanCreate"; + + /// User can view/read entities and data. + public const string CanRead = "CanRead"; + + /// User can modify existing entities. + public const string CanUpdate = "CanUpdate"; + + /// User can delete entities. + public const string CanDelete = "CanDelete"; + + /// User can execute tasks, run workflows, manage agent operations. + public const string CanExecute = "CanExecute"; + + /// User has full administrative access. + public const string IsAdmin = "IsAdmin"; +} diff --git a/src/Werkr.Common/Auth/WerkrClaimTypes.cs b/src/Werkr.Common/Auth/WerkrClaimTypes.cs new file mode 100644 index 0000000..4252453 --- /dev/null +++ b/src/Werkr.Common/Auth/WerkrClaimTypes.cs @@ -0,0 +1,15 @@ +namespace Werkr.Common.Auth; + +/// +/// Shared JWT claim type constants used by both the Server (issuer) and API (validator). +/// +public static class WerkrClaimTypes { + /// Claim type for permission values embedded in JWTs. + public const string Permission = "permission"; + + /// Claim type for the API key identifier. + public const string ApiKeyId = "api_key_id"; + + /// Claim type for the API key display name. + public const string ApiKeyName = "api_key_name"; +} diff --git a/src/Werkr.Common/Configuration/Registry/RegistryConfigurationExtensions.cs b/src/Werkr.Common/Configuration/Registry/RegistryConfigurationExtensions.cs new file mode 100644 index 0000000..22e3b17 --- /dev/null +++ b/src/Werkr.Common/Configuration/Registry/RegistryConfigurationExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Configuration; + +namespace Werkr.Common.Configuration.Registry; + +/// +/// Extension methods for adding +/// to an . +/// +public static class RegistryConfigurationExtensions { + /// + /// Adds the Windows Registry as a configuration source. + /// Reads from HKLM\SOFTWARE\Werkr\{subKey}. + /// On non-Windows platforms this is a safe no-op. + /// + /// The configuration builder. + /// + /// The sub-key under SOFTWARE\Werkr to read. + /// For example "Agent" or "Server". + /// Pass or empty to read the root key. + /// + /// The builder for chaining. + public static IConfigurationBuilder AddWerkrRegistry( + this IConfigurationBuilder builder, + string subKey = "" ) { + return builder.Add( new RegistryConfigurationSource { SubKey = subKey } ); + } +} diff --git a/src/Werkr.Common/Configuration/Registry/RegistryConfigurationProvider.cs b/src/Werkr.Common/Configuration/Registry/RegistryConfigurationProvider.cs new file mode 100644 index 0000000..ff55692 --- /dev/null +++ b/src/Werkr.Common/Configuration/Registry/RegistryConfigurationProvider.cs @@ -0,0 +1,84 @@ +using System.Runtime.InteropServices; +using Microsoft.Extensions.Configuration; + +namespace Werkr.Common.Configuration.Registry; + +/// +/// Reads configuration values from the Windows Registry. +/// +/// Registry keys map to configuration paths using : as separator. +/// For example, HKLM\SOFTWARE\Werkr\Agent with value Name = "MyAgent" +/// becomes the configuration path Agent:Name. +/// +/// +/// Sub-keys are traversed recursively. On non-Windows platforms the provider +/// returns no data. +/// +/// +public sealed class RegistryConfigurationProvider : ConfigurationProvider { + private readonly RegistryConfigurationSource _source; + + /// + /// Initializes a new instance of . + /// + /// The source configuration. + public RegistryConfigurationProvider( RegistryConfigurationSource source ) { + _source = source ?? throw new ArgumentNullException( nameof( source ) ); + } + + /// + public override void Load( ) { + Dictionary data = new( StringComparer.OrdinalIgnoreCase ); + + if (RuntimeInformation.IsOSPlatform( OSPlatform.Windows )) { + ReadRegistryWindows( data ); + } + + Data = data; + } + + [System.Runtime.Versioning.SupportedOSPlatform( "windows" )] + private void ReadRegistryWindows( Dictionary data ) { + string registryPath = string.IsNullOrEmpty( _source.SubKey ) + ? _source.RootPath + : $@"{_source.RootPath}\{_source.SubKey}"; + + using Microsoft.Win32.RegistryKey? key = + Microsoft.Win32.Registry.LocalMachine.OpenSubKey( registryPath ); + + if (key is null) { + return; + } + + ReadKeyRecursive( key, _source.SubKey, data ); + } + + [System.Runtime.Versioning.SupportedOSPlatform( "windows" )] + private static void ReadKeyRecursive( + Microsoft.Win32.RegistryKey key, + string prefix, + Dictionary data ) { + // Read values at this level + foreach (string valueName in key.GetValueNames( )) { + string configKey = string.IsNullOrEmpty( prefix ) + ? valueName + : $"{prefix}:{valueName}"; + + object? value = key.GetValue( valueName ); + if (value is not null) { + data[configKey] = value.ToString( ); + } + } + + // Recurse into sub-keys + foreach (string subKeyName in key.GetSubKeyNames( )) { + using Microsoft.Win32.RegistryKey? subKey = key.OpenSubKey( subKeyName ); + if (subKey is not null) { + string subPrefix = string.IsNullOrEmpty( prefix ) + ? subKeyName + : $"{prefix}:{subKeyName}"; + ReadKeyRecursive( subKey, subPrefix, data ); + } + } + } +} diff --git a/src/Werkr.Common/Configuration/Registry/RegistryConfigurationSource.cs b/src/Werkr.Common/Configuration/Registry/RegistryConfigurationSource.cs new file mode 100644 index 0000000..95be172 --- /dev/null +++ b/src/Werkr.Common/Configuration/Registry/RegistryConfigurationSource.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Configuration; + +namespace Werkr.Common.Configuration.Registry; + +/// +/// An that reads settings from the Windows +/// Registry under HKLM\SOFTWARE\Werkr\{SubKey}. +/// +/// On non-Windows platforms this source is a no-op — it returns an empty provider. +/// +/// +public sealed class RegistryConfigurationSource : IConfigurationSource { + /// + /// The registry sub-key under HKLM\SOFTWARE\Werkr\. + /// For example "Agent" reads HKLM\SOFTWARE\Werkr\Agent. + /// + public string SubKey { get; set; } = string.Empty; + + /// + /// Root path under HKLM. Default is SOFTWARE\Werkr. + /// + public string RootPath { get; set; } = @"SOFTWARE\Werkr"; + + /// + public IConfigurationProvider Build( IConfigurationBuilder builder ) { + return new RegistryConfigurationProvider( this ); + } +} diff --git a/src/Werkr.Common/DatabaseProvider.cs b/src/Werkr.Common/DatabaseProvider.cs new file mode 100644 index 0000000..adeb625 --- /dev/null +++ b/src/Werkr.Common/DatabaseProvider.cs @@ -0,0 +1,10 @@ +namespace Werkr.Common; + +/// Database provider type. +public enum DatabaseProvider { + /// PostgreSQL database. + Postgres, + + /// SQLite database (typically for agents or local development). + SQLite, +} diff --git a/src/Werkr.Common/Extensions/ConfigurationBuilderExtensions.cs b/src/Werkr.Common/Extensions/ConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..73401b4 --- /dev/null +++ b/src/Werkr.Common/Extensions/ConfigurationBuilderExtensions.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Configuration; +using Werkr.Common.Configuration.Registry; + +namespace Werkr.Common.Extensions; + +/// +/// Extension methods for . +/// +public static class ConfigurationBuilderExtensions { + /// + /// Adds platform-specific configuration sources for Werkr: + /// + /// + /// Windows: reads from HKLM\SOFTWARE\Werkr\{subKey} via + /// . + /// + /// + /// Linux / Docker: reads a JSON file specified by the + /// WERKR_CONFIG_PATH environment variable. + /// + /// + /// + /// The configuration builder. + /// + /// The registry sub-key under SOFTWARE\Werkr to read on Windows. + /// For example "Agent" or "Server". Ignored on non-Windows platforms. + /// + /// The configuration builder for chaining. + public static IConfigurationBuilder AddWerkrConfigPath( + this IConfigurationBuilder builder, + string registrySubKey = "" ) { + // Windows: read from HKLM\SOFTWARE\Werkr\{subKey} + _ = builder.AddWerkrRegistry( registrySubKey ); + + // Linux / Docker: read from WERKR_CONFIG_PATH env var + string? configPath = Environment.GetEnvironmentVariable( "WERKR_CONFIG_PATH" ); + if (!string.IsNullOrEmpty( configPath )) { + _ = builder.AddJsonFile( configPath, optional: true, reloadOnChange: true ); + } + return builder; + } +} diff --git a/src/Werkr.Common/Models/ActionOperatorConfiguration.cs b/src/Werkr.Common/Models/ActionOperatorConfiguration.cs new file mode 100644 index 0000000..f20628e --- /dev/null +++ b/src/Werkr.Common/Models/ActionOperatorConfiguration.cs @@ -0,0 +1,18 @@ +namespace Werkr.Common.Models; + +/// +/// Configuration options for the ActionOperator — the built-in action +/// handler dispatch engine. Bound from the "ActionOperator" configuration section. +/// +public sealed class ActionOperatorConfiguration { + + /// The configuration section name. + public const string SectionName = "ActionOperator"; + + /// + /// Default timeout applied to each action handler invocation. + /// Set to null to disable the timeout entirely. + /// Default: 1 hour. + /// + public TimeSpan? DefaultTimeout { get; set; } = TimeSpan.FromHours( 1 ); +} diff --git a/src/Werkr.Common/Models/Actions/ActionDescriptor.cs b/src/Werkr.Common/Models/Actions/ActionDescriptor.cs new file mode 100644 index 0000000..f2d713f --- /dev/null +++ b/src/Werkr.Common/Models/Actions/ActionDescriptor.cs @@ -0,0 +1,20 @@ +using System.Text.Json; + +namespace Werkr.Common.Models.Actions; + +/// +/// Describes a built-in action to execute. A pure data record — serialization +/// options are handled at the handler layer via ActionJson.SerializerOptions +/// (case-insensitive on deserialization at consumption). Serialization at creation +/// uses +/// with camelCase policy at the call site. This record is intentionally a pure +/// data carrier. +/// +public sealed record ActionDescriptor { + + /// The action name string (e.g. "CopyFile", "StartProcess"). + public required string Action { get; init; } + + /// JSON parameters matching the action's parameter record shape. + public required JsonElement Parameters { get; init; } +} diff --git a/src/Werkr.Common/Models/Actions/ClearContentParameters.cs b/src/Werkr.Common/Models/Actions/ClearContentParameters.cs new file mode 100644 index 0000000..0fa36d7 --- /dev/null +++ b/src/Werkr.Common/Models/Actions/ClearContentParameters.cs @@ -0,0 +1,7 @@ +namespace Werkr.Common.Models.Actions; + +/// Parameters for the ClearContent action. +public sealed record ClearContentParameters { + /// Full path of the file to truncate. + public required string Path { get; init; } +} diff --git a/src/Werkr.Common/Models/Actions/CopyFileParameters.cs b/src/Werkr.Common/Models/Actions/CopyFileParameters.cs new file mode 100644 index 0000000..85b7b34 --- /dev/null +++ b/src/Werkr.Common/Models/Actions/CopyFileParameters.cs @@ -0,0 +1,16 @@ +namespace Werkr.Common.Models.Actions; + +/// Parameters for the CopyFile action. +public sealed record CopyFileParameters { + /// Source file or directory path. Supports wildcard patterns. + public required string Source { get; init; } + + /// Destination file or directory path. + public required string Destination { get; init; } + + /// Whether to overwrite existing files at the destination. + public bool Overwrite { get; init; } + + /// Whether to copy directories recursively. + public bool Recursive { get; init; } +} diff --git a/src/Werkr.Common/Models/Actions/CreateDirectoryParameters.cs b/src/Werkr.Common/Models/Actions/CreateDirectoryParameters.cs new file mode 100644 index 0000000..5d41a53 --- /dev/null +++ b/src/Werkr.Common/Models/Actions/CreateDirectoryParameters.cs @@ -0,0 +1,7 @@ +namespace Werkr.Common.Models.Actions; + +/// Parameters for the CreateDirectory action. +public sealed record CreateDirectoryParameters { + /// Full path of the directory to create. + public required string Path { get; init; } +} diff --git a/src/Werkr.Common/Models/Actions/CreateFileParameters.cs b/src/Werkr.Common/Models/Actions/CreateFileParameters.cs new file mode 100644 index 0000000..e6839e1 --- /dev/null +++ b/src/Werkr.Common/Models/Actions/CreateFileParameters.cs @@ -0,0 +1,19 @@ +namespace Werkr.Common.Models.Actions; + +/// Parameters for the CreateFile action. +public sealed record CreateFileParameters { + /// Full path of the file to create. + public required string Path { get; init; } + + /// Optional content to write into the new file. + public string? Content { get; init; } + + /// Whether to overwrite the file if it already exists. + public bool Overwrite { get; init; } + + /// Text encoding for the file content (e.g. "utf-8", "ascii"). + public string Encoding { get; init; } = "utf-8"; + + /// Whether to create parent directories if they do not exist. + public bool CreateParentDirectories { get; init; } = true; +} diff --git a/src/Werkr.Common/Models/Actions/DeleteFileParameters.cs b/src/Werkr.Common/Models/Actions/DeleteFileParameters.cs new file mode 100644 index 0000000..c129e0f --- /dev/null +++ b/src/Werkr.Common/Models/Actions/DeleteFileParameters.cs @@ -0,0 +1,13 @@ +namespace Werkr.Common.Models.Actions; + +/// Parameters for the DeleteFile action. +public sealed record DeleteFileParameters { + /// Path of the file or directory to delete. + public required string Path { get; init; } + + /// Whether to delete directories recursively. + public bool Recursive { get; init; } + + /// Whether to remove read-only attributes before deletion. + public bool Force { get; init; } +} diff --git a/src/Werkr.Common/Models/Actions/MoveFileParameters.cs b/src/Werkr.Common/Models/Actions/MoveFileParameters.cs new file mode 100644 index 0000000..e8688cb --- /dev/null +++ b/src/Werkr.Common/Models/Actions/MoveFileParameters.cs @@ -0,0 +1,13 @@ +namespace Werkr.Common.Models.Actions; + +/// Parameters for the MoveFile action. +public sealed record MoveFileParameters { + /// Source file or directory path. Supports wildcard patterns. + public required string Source { get; init; } + + /// Destination file or directory path. + public required string Destination { get; init; } + + /// Whether to overwrite existing files at the destination. + public bool Overwrite { get; init; } +} diff --git a/src/Werkr.Common/Models/Actions/RenameFileParameters.cs b/src/Werkr.Common/Models/Actions/RenameFileParameters.cs new file mode 100644 index 0000000..93be614 --- /dev/null +++ b/src/Werkr.Common/Models/Actions/RenameFileParameters.cs @@ -0,0 +1,13 @@ +namespace Werkr.Common.Models.Actions; + +/// Parameters for the RenameFile action. +public sealed record RenameFileParameters { + /// Full path of the file or directory to rename. + public required string Path { get; init; } + + /// New name (not a full path — just the file or directory name). + public required string NewName { get; init; } + + /// Whether to overwrite an existing item with the same name. + public bool Overwrite { get; init; } +} diff --git a/src/Werkr.Common/Models/Actions/StartProcessParameters.cs b/src/Werkr.Common/Models/Actions/StartProcessParameters.cs new file mode 100644 index 0000000..93f91e5 --- /dev/null +++ b/src/Werkr.Common/Models/Actions/StartProcessParameters.cs @@ -0,0 +1,19 @@ +namespace Werkr.Common.Models.Actions; + +/// Parameters for the StartProcess action. +public sealed record StartProcessParameters { + /// Path to the executable or command to start. + public required string FileName { get; init; } + + /// Optional command-line arguments. + public string? Arguments { get; init; } + + /// Optional working directory for the process. + public string? WorkingDirectory { get; init; } + + /// Whether to wait for the process to exit before completing. + public bool WaitForExit { get; init; } + + /// Timeout in milliseconds when is true. Null means wait indefinitely. + public int? TimeoutMs { get; init; } +} diff --git a/src/Werkr.Common/Models/Actions/StopProcessParameters.cs b/src/Werkr.Common/Models/Actions/StopProcessParameters.cs new file mode 100644 index 0000000..13ec4ce --- /dev/null +++ b/src/Werkr.Common/Models/Actions/StopProcessParameters.cs @@ -0,0 +1,13 @@ +namespace Werkr.Common.Models.Actions; + +/// Parameters for the StopProcess action. +public sealed record StopProcessParameters { + /// Name of the process to stop. + public required string ProcessName { get; init; } + + /// Optional specific process ID. When set, only this PID is stopped. + public int? ProcessId { get; init; } + + /// Whether to forcefully terminate the process. + public bool Force { get; init; } +} diff --git a/src/Werkr.Common/Models/Actions/TestExistsParameters.cs b/src/Werkr.Common/Models/Actions/TestExistsParameters.cs new file mode 100644 index 0000000..ad08b78 --- /dev/null +++ b/src/Werkr.Common/Models/Actions/TestExistsParameters.cs @@ -0,0 +1,10 @@ +namespace Werkr.Common.Models.Actions; + +/// Parameters for the TestExists action. +public sealed record TestExistsParameters { + /// Path to test for existence. + public required string Path { get; init; } + + /// What type of path to check: File, Directory, or Any. + public PathType Type { get; init; } = PathType.Any; +} diff --git a/src/Werkr.Common/Models/Actions/WriteContentParameters.cs b/src/Werkr.Common/Models/Actions/WriteContentParameters.cs new file mode 100644 index 0000000..d877208 --- /dev/null +++ b/src/Werkr.Common/Models/Actions/WriteContentParameters.cs @@ -0,0 +1,16 @@ +namespace Werkr.Common.Models.Actions; + +/// Parameters for the WriteContent action. +public sealed record WriteContentParameters { + /// Full path of the file to write to. + public required string Path { get; init; } + + /// Content to write. + public required string Content { get; init; } + + /// Whether to append to the file instead of overwriting. + public bool Append { get; init; } + + /// Text encoding (e.g. "utf-8", "ascii"). + public string Encoding { get; init; } = "utf-8"; +} diff --git a/src/Werkr.Common/Models/AgentActivityDto.cs b/src/Werkr.Common/Models/AgentActivityDto.cs new file mode 100644 index 0000000..f54316d --- /dev/null +++ b/src/Werkr.Common/Models/AgentActivityDto.cs @@ -0,0 +1,9 @@ +namespace Werkr.Common.Models; + +/// Agent activity timeline entry. +public sealed record AgentActivityDto( + Guid AgentId, + string ConnectionName, + string EventType, + DateTime OccurredAtUtc, + string Status ); diff --git a/src/Werkr.Common/Models/AgentConnectionDto.cs b/src/Werkr.Common/Models/AgentConnectionDto.cs new file mode 100644 index 0000000..0871ecc --- /dev/null +++ b/src/Werkr.Common/Models/AgentConnectionDto.cs @@ -0,0 +1,21 @@ +namespace Werkr.Common.Models; + +/// +/// Lightweight connection metadata for the Server UI. +/// Sensitive fields (OutboundApiKey, LocalPrivateKey, SharedKey) are never exposed. +/// +/// Connection unique identifier. +/// Human-readable name. +/// Agent's gRPC endpoint URL. +/// Current connection status. +/// Last communication timestamp. +/// When the connection was established. +/// Tags associated with this agent connection. +public sealed record AgentConnectionDto( + Guid Id, + string ConnectionName, + string RemoteUrl, + string Status, + DateTime? LastSeen, + DateTime RegisteredAt, + string[] Tags ); diff --git a/src/Werkr.Common/Models/AgentDetailDto.cs b/src/Werkr.Common/Models/AgentDetailDto.cs new file mode 100644 index 0000000..99ca68f --- /dev/null +++ b/src/Werkr.Common/Models/AgentDetailDto.cs @@ -0,0 +1,13 @@ +namespace Werkr.Common.Models; + +/// Detailed view for a single agent. +public sealed record AgentDetailDto( + Guid Id, + string ConnectionName, + string RemoteUrl, + string Status, + string RsaKeyFingerprint, + DateTime RegisteredAt, + DateTime? LastSeen, + bool? PowerShellAvailable, + bool? SystemShellAvailable ); diff --git a/src/Werkr.Common/Models/AgentHealthDto.cs b/src/Werkr.Common/Models/AgentHealthDto.cs new file mode 100644 index 0000000..568d646 --- /dev/null +++ b/src/Werkr.Common/Models/AgentHealthDto.cs @@ -0,0 +1,11 @@ +namespace Werkr.Common.Models; + +/// Health summary for a single agent. +public sealed record AgentHealthDto( + Guid AgentId, + string ConnectionName, + string Status, + bool? PowerShellAvailable, + bool? SystemShellAvailable, + DateTime? LastSeen, + DateTime? HealthCheckedAt ); diff --git a/src/Werkr.Common/Models/AgentListDto.cs b/src/Werkr.Common/Models/AgentListDto.cs new file mode 100644 index 0000000..1fe874c --- /dev/null +++ b/src/Werkr.Common/Models/AgentListDto.cs @@ -0,0 +1,16 @@ +namespace Werkr.Common.Models; + +/// Agent summary for the agents list endpoint. +/// Connection unique identifier. +/// Human-readable name. +/// Agent's gRPC endpoint URL. +/// Current connection status. +/// Last communication timestamp. +/// When the connection was established. +public sealed record AgentListDto( + Guid Id, + string ConnectionName, + string RemoteUrl, + string Status, + DateTime? LastSeen, + DateTime RegisteredAt ); diff --git a/src/Werkr.Common/Models/AgentStatus.cs b/src/Werkr.Common/Models/AgentStatus.cs new file mode 100644 index 0000000..773c340 --- /dev/null +++ b/src/Werkr.Common/Models/AgentStatus.cs @@ -0,0 +1,16 @@ +namespace Werkr.Common.Models; + +/// Agent connection status. +public enum AgentStatus { + /// Agent has completed registration. + Registered = 0, + + /// Agent is currently connected and responding. + Connected = 1, + + /// Agent is not responding. + Disconnected = 2, + + /// Agent is in an error state. + Error = 3, +} diff --git a/src/Werkr.Common/Models/AllowedPathsConfiguration.cs b/src/Werkr.Common/Models/AllowedPathsConfiguration.cs new file mode 100644 index 0000000..2c9abb2 --- /dev/null +++ b/src/Werkr.Common/Models/AllowedPathsConfiguration.cs @@ -0,0 +1,25 @@ +namespace Werkr.Common.Models; + +/// +/// Configuration model for the per-agent path allowlist. +/// Bound from agent configuration and optionally synced from the server +/// via the ScheduleSync payload. +/// +public sealed class AllowedPathsConfiguration { + /// Configuration section name for options binding. + public const string SectionName = "AllowedPaths"; + + /// + /// List of permitted filesystem path prefixes (e.g. ["/data", "/home/werkr"]). + /// All action handler file operations are checked against these prefixes + /// when is true. + /// + public List Paths { get; set; } = []; + + /// + /// When true, all file/content/process action handlers validate paths + /// against before executing. When false (default), + /// all paths are permitted for backward compatibility. + /// + public bool EnforceAllowlist { get; set; } +} diff --git a/src/Werkr.Common/Models/ApiKeyCreateRequest.cs b/src/Werkr.Common/Models/ApiKeyCreateRequest.cs new file mode 100644 index 0000000..21316b7 --- /dev/null +++ b/src/Werkr.Common/Models/ApiKeyCreateRequest.cs @@ -0,0 +1,6 @@ +namespace Werkr.Common.Models; + +/// Request body for creating a new API key. +public sealed record ApiKeyCreateRequest( + string Name, + DateTime? ExpiresUtc = null ); diff --git a/src/Werkr.Common/Models/ApiKeyCreateResponse.cs b/src/Werkr.Common/Models/ApiKeyCreateResponse.cs new file mode 100644 index 0000000..028a1b2 --- /dev/null +++ b/src/Werkr.Common/Models/ApiKeyCreateResponse.cs @@ -0,0 +1,14 @@ +namespace Werkr.Common.Models; + +/// +/// Response body from API key creation. +/// Contains the raw key value (only available at creation time). +/// +public sealed record ApiKeyCreateResponse( + Guid Id, + string Name, + string RawKey, + string KeyPrefix, + string Role, + DateTime CreatedUtc, + DateTime? ExpiresUtc ); diff --git a/src/Werkr.Common/Models/ApiKeyDto.cs b/src/Werkr.Common/Models/ApiKeyDto.cs new file mode 100644 index 0000000..e6078a3 --- /dev/null +++ b/src/Werkr.Common/Models/ApiKeyDto.cs @@ -0,0 +1,13 @@ +namespace Werkr.Common.Models; + +/// DTO for listing API keys (without the raw key value). +public sealed record ApiKeyDto( + Guid Id, + string Name, + string KeyPrefix, + string Role, + string CreatedByUserId, + DateTime CreatedUtc, + DateTime? ExpiresUtc, + bool IsRevoked, + DateTime? LastUsedUtc ); diff --git a/src/Werkr.Common/Models/ConnectionStatus.cs b/src/Werkr.Common/Models/ConnectionStatus.cs new file mode 100644 index 0000000..8ca4a73 --- /dev/null +++ b/src/Werkr.Common/Models/ConnectionStatus.cs @@ -0,0 +1,16 @@ +namespace Werkr.Common.Models; + +/// Status of an established connection between Server and Agent. +public enum ConnectionStatus { + /// Connection is active and operational. + Connected = 0, + + /// Remote endpoint is not responding. + Disconnected = 1, + + /// Connection is in an error state. + Error = 2, + + /// Connection has been revoked by an admin. + Revoked = 3, +} diff --git a/src/Werkr.Common/Models/DagValidationResult.cs b/src/Werkr.Common/Models/DagValidationResult.cs new file mode 100644 index 0000000..534da6d --- /dev/null +++ b/src/Werkr.Common/Models/DagValidationResult.cs @@ -0,0 +1,6 @@ +namespace Werkr.Common.Models; + +/// Result of DAG validation for a workflow. +public sealed record DagValidationResult( + bool IsValid, + IReadOnlyList Errors ); diff --git a/src/Werkr.Common/Models/DailyRecurrenceDto.cs b/src/Werkr.Common/Models/DailyRecurrenceDto.cs new file mode 100644 index 0000000..0fb1922 --- /dev/null +++ b/src/Werkr.Common/Models/DailyRecurrenceDto.cs @@ -0,0 +1,5 @@ +namespace Werkr.Common.Models; + +/// DTO for DailyRecurrence. +/// Recur every N days. +public sealed record DailyRecurrenceDto( int DayInterval ); diff --git a/src/Werkr.Common/Models/DatabaseHealthDto.cs b/src/Werkr.Common/Models/DatabaseHealthDto.cs new file mode 100644 index 0000000..670f9f0 --- /dev/null +++ b/src/Werkr.Common/Models/DatabaseHealthDto.cs @@ -0,0 +1,10 @@ +namespace Werkr.Common.Models; + +/// Database diagnostics model. +public sealed record DatabaseHealthDto( + string ContextName, + string ProviderName, + bool IsConnected, + int AppliedMigrationCount, + int PendingMigrationCount, + List PendingMigrations ); diff --git a/src/Werkr.Common/Models/ExecuteCommandRequest.cs b/src/Werkr.Common/Models/ExecuteCommandRequest.cs new file mode 100644 index 0000000..8d3737d --- /dev/null +++ b/src/Werkr.Common/Models/ExecuteCommandRequest.cs @@ -0,0 +1,10 @@ +namespace Werkr.Common.Models; + +/// Request DTO for the command execution endpoint. +/// The operator type string ("PowerShell" or "SystemShell"). +/// The plaintext command to execute. +/// Timeout in minutes. Defaults to 30. +public sealed record ExecuteCommandRequest( + string OperatorType, + string Command, + int TimeoutMinutes = 30 ); diff --git a/src/Werkr.Common/Models/ExecuteCommandResponse.cs b/src/Werkr.Common/Models/ExecuteCommandResponse.cs new file mode 100644 index 0000000..2b2d4f0 --- /dev/null +++ b/src/Werkr.Common/Models/ExecuteCommandResponse.cs @@ -0,0 +1,10 @@ +namespace Werkr.Common.Models; + +/// Response DTO for the command execution endpoint. +/// Whether the execution completed without error. +/// The collected operator output lines. +/// Optional error message when is false. +public sealed record ExecuteCommandResponse( + bool Success, + List Output, + string? Error = null ); diff --git a/src/Werkr.Common/Models/ExpirationDateTimeDto.cs b/src/Werkr.Common/Models/ExpirationDateTimeDto.cs new file mode 100644 index 0000000..b2028ba --- /dev/null +++ b/src/Werkr.Common/Models/ExpirationDateTimeDto.cs @@ -0,0 +1,7 @@ +namespace Werkr.Common.Models; + +/// DTO for ExpirationDateTimeInfo. +/// Expiration date. +/// Expiration time. +/// IANA or Windows timezone identifier. +public sealed record ExpirationDateTimeDto( DateOnly Date, TimeOnly Time, string TimeZoneId ); diff --git a/src/Werkr.Common/Models/Holidays/AttachHolidayCalendarRequest.cs b/src/Werkr.Common/Models/Holidays/AttachHolidayCalendarRequest.cs new file mode 100644 index 0000000..f2d9b55 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/AttachHolidayCalendarRequest.cs @@ -0,0 +1,6 @@ +namespace Werkr.Common.Models.Holidays; + +/// Attach a holiday calendar to a schedule. +public sealed record AttachHolidayCalendarRequest( + Guid CalendarId, + string Mode ); diff --git a/src/Werkr.Common/Models/Holidays/BulkHolidayDateCreateRequest.cs b/src/Werkr.Common/Models/Holidays/BulkHolidayDateCreateRequest.cs new file mode 100644 index 0000000..6f9f220 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/BulkHolidayDateCreateRequest.cs @@ -0,0 +1,5 @@ +namespace Werkr.Common.Models.Holidays; + +/// Bulk create-date request. +public sealed record BulkHolidayDateCreateRequest( + IReadOnlyList Dates ); diff --git a/src/Werkr.Common/Models/Holidays/BulkScheduleHolidayDatesResponse.cs b/src/Werkr.Common/Models/Holidays/BulkScheduleHolidayDatesResponse.cs new file mode 100644 index 0000000..e866ee0 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/BulkScheduleHolidayDatesResponse.cs @@ -0,0 +1,5 @@ +namespace Werkr.Common.Models.Holidays; + +/// Batched holiday dates response. +public sealed record BulkScheduleHolidayDatesResponse( + IReadOnlyList Results ); diff --git a/src/Werkr.Common/Models/Holidays/CloneHolidayCalendarRequest.cs b/src/Werkr.Common/Models/Holidays/CloneHolidayCalendarRequest.cs new file mode 100644 index 0000000..96cdf19 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/CloneHolidayCalendarRequest.cs @@ -0,0 +1,5 @@ +namespace Werkr.Common.Models.Holidays; + +/// Clone-calendar request. +public sealed record CloneHolidayCalendarRequest( + string NewName ); diff --git a/src/Werkr.Common/Models/Holidays/HolidayCalendarCreateRequest.cs b/src/Werkr.Common/Models/Holidays/HolidayCalendarCreateRequest.cs new file mode 100644 index 0000000..cb2005f --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/HolidayCalendarCreateRequest.cs @@ -0,0 +1,6 @@ +namespace Werkr.Common.Models.Holidays; + +/// Create-calendar request. +public sealed record HolidayCalendarCreateRequest( + string Name, + string Description ); diff --git a/src/Werkr.Common/Models/Holidays/HolidayCalendarDto.cs b/src/Werkr.Common/Models/Holidays/HolidayCalendarDto.cs new file mode 100644 index 0000000..be84782 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/HolidayCalendarDto.cs @@ -0,0 +1,12 @@ +namespace Werkr.Common.Models.Holidays; + +/// Full holiday calendar with all rules and dates. +public sealed record HolidayCalendarDto( + Guid Id, + string Name, + string Description, + bool IsSystemCalendar, + DateTime CreatedUtc, + DateTime UpdatedUtc, + IReadOnlyList Rules, + IReadOnlyList Dates ); diff --git a/src/Werkr.Common/Models/Holidays/HolidayCalendarSummaryDto.cs b/src/Werkr.Common/Models/Holidays/HolidayCalendarSummaryDto.cs new file mode 100644 index 0000000..b72b824 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/HolidayCalendarSummaryDto.cs @@ -0,0 +1,10 @@ +namespace Werkr.Common.Models.Holidays; + +/// Calendar list item with counts. +public sealed record HolidayCalendarSummaryDto( + Guid Id, + string Name, + string Description, + bool IsSystemCalendar, + int RuleCount, + int AttachedScheduleCount ); diff --git a/src/Werkr.Common/Models/Holidays/HolidayCalendarUpdateRequest.cs b/src/Werkr.Common/Models/Holidays/HolidayCalendarUpdateRequest.cs new file mode 100644 index 0000000..17cb0ab --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/HolidayCalendarUpdateRequest.cs @@ -0,0 +1,6 @@ +namespace Werkr.Common.Models.Holidays; + +/// Update-calendar request. +public sealed record HolidayCalendarUpdateRequest( + string Name, + string Description ); diff --git a/src/Werkr.Common/Models/Holidays/HolidayDateCreateRequest.cs b/src/Werkr.Common/Models/Holidays/HolidayDateCreateRequest.cs new file mode 100644 index 0000000..d4adf59 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/HolidayDateCreateRequest.cs @@ -0,0 +1,9 @@ +namespace Werkr.Common.Models.Holidays; + +/// Create-date request. +public sealed record HolidayDateCreateRequest( + string Date, + string Name, + string? WindowStart, + string? WindowEnd, + string? WindowTimeZoneId ); diff --git a/src/Werkr.Common/Models/Holidays/HolidayDateDto.cs b/src/Werkr.Common/Models/Holidays/HolidayDateDto.cs new file mode 100644 index 0000000..5573716 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/HolidayDateDto.cs @@ -0,0 +1,14 @@ +namespace Werkr.Common.Models.Holidays; + +/// Full holiday date. +public sealed record HolidayDateDto( + long Id, + Guid HolidayCalendarId, + string Date, + string Name, + int Year, + string? WindowStart, + string? WindowEnd, + string? WindowTimeZoneId, + bool IsManual, + long? GeneratedByRuleId ); diff --git a/src/Werkr.Common/Models/Holidays/HolidayPreviewResponse.cs b/src/Werkr.Common/Models/Holidays/HolidayPreviewResponse.cs new file mode 100644 index 0000000..43dfd49 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/HolidayPreviewResponse.cs @@ -0,0 +1,8 @@ +namespace Werkr.Common.Models.Holidays; + +/// Calendar preview response. +public sealed record HolidayPreviewResponse( + Guid CalendarId, + int StartYear, + int EndYear, + IReadOnlyList Dates ); diff --git a/src/Werkr.Common/Models/Holidays/HolidayRuleCreateRequest.cs b/src/Werkr.Common/Models/Holidays/HolidayRuleCreateRequest.cs new file mode 100644 index 0000000..3ca67ee --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/HolidayRuleCreateRequest.cs @@ -0,0 +1,16 @@ +namespace Werkr.Common.Models.Holidays; + +/// Create-rule request. +public sealed record HolidayRuleCreateRequest( + string Name, + string RuleType, + int? Month, + int? Day, + string? DayOfWeek, + int? WeekNumber, + string? WindowStart, + string? WindowEnd, + string? WindowTimeZoneId, + string ObservanceRule, + int? YearStart, + int? YearEnd ); diff --git a/src/Werkr.Common/Models/Holidays/HolidayRuleDto.cs b/src/Werkr.Common/Models/Holidays/HolidayRuleDto.cs new file mode 100644 index 0000000..8e55dea --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/HolidayRuleDto.cs @@ -0,0 +1,18 @@ +namespace Werkr.Common.Models.Holidays; + +/// Full holiday rule. +public sealed record HolidayRuleDto( + long Id, + Guid HolidayCalendarId, + string Name, + string RuleType, + int? Month, + int? Day, + string? DayOfWeek, + int? WeekNumber, + string? WindowStart, + string? WindowEnd, + string? WindowTimeZoneId, + string ObservanceRule, + int? YearStart, + int? YearEnd ); diff --git a/src/Werkr.Common/Models/Holidays/HolidayRuleUpdateRequest.cs b/src/Werkr.Common/Models/Holidays/HolidayRuleUpdateRequest.cs new file mode 100644 index 0000000..66c0efc --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/HolidayRuleUpdateRequest.cs @@ -0,0 +1,16 @@ +namespace Werkr.Common.Models.Holidays; + +/// Update-rule request. +public sealed record HolidayRuleUpdateRequest( + string Name, + string RuleType, + int? Month, + int? Day, + string? DayOfWeek, + int? WeekNumber, + string? WindowStart, + string? WindowEnd, + string? WindowTimeZoneId, + string ObservanceRule, + int? YearStart, + int? YearEnd ); diff --git a/src/Werkr.Common/Models/Holidays/RulePreviewRequest.cs b/src/Werkr.Common/Models/Holidays/RulePreviewRequest.cs new file mode 100644 index 0000000..71b3b96 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/RulePreviewRequest.cs @@ -0,0 +1,16 @@ +namespace Werkr.Common.Models.Holidays; + +/// Rule preview request (without persisting). +public sealed record RulePreviewRequest( + string Name, + string RuleType, + int? Month, + int? Day, + string? DayOfWeek, + int? WeekNumber, + string? WindowStart, + string? WindowEnd, + string? WindowTimeZoneId, + string ObservanceRule, + int? YearStart, + int? YearEnd ); diff --git a/src/Werkr.Common/Models/Holidays/RulePreviewResponse.cs b/src/Werkr.Common/Models/Holidays/RulePreviewResponse.cs new file mode 100644 index 0000000..968859a --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/RulePreviewResponse.cs @@ -0,0 +1,7 @@ +namespace Werkr.Common.Models.Holidays; + +/// Rule preview response. +public sealed record RulePreviewResponse( + int StartYear, + int EndYear, + IReadOnlyList Dates ); diff --git a/src/Werkr.Common/Models/Holidays/ScheduleAuditLogCreateRequest.cs b/src/Werkr.Common/Models/Holidays/ScheduleAuditLogCreateRequest.cs new file mode 100644 index 0000000..a3a9e02 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/ScheduleAuditLogCreateRequest.cs @@ -0,0 +1,7 @@ +namespace Werkr.Common.Models.Holidays; + +/// Create-audit-log request. +public sealed record ScheduleAuditLogCreateRequest( + DateTime OccurrenceUtcTime, + string HolidayName, + string Reason ); diff --git a/src/Werkr.Common/Models/Holidays/ScheduleAuditLogDto.cs b/src/Werkr.Common/Models/Holidays/ScheduleAuditLogDto.cs new file mode 100644 index 0000000..6608bc4 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/ScheduleAuditLogDto.cs @@ -0,0 +1,11 @@ +namespace Werkr.Common.Models.Holidays; + +/// Full audit log record. +public sealed record ScheduleAuditLogDto( + long Id, + Guid ScheduleId, + DateTime OccurrenceUtcTime, + string CalendarName, + string HolidayName, + string Mode, + DateTime CreatedUtc ); diff --git a/src/Werkr.Common/Models/Holidays/ScheduleHolidayCalendarDto.cs b/src/Werkr.Common/Models/Holidays/ScheduleHolidayCalendarDto.cs new file mode 100644 index 0000000..99618b8 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/ScheduleHolidayCalendarDto.cs @@ -0,0 +1,7 @@ +namespace Werkr.Common.Models.Holidays; + +/// Attached calendar info for a schedule. +public sealed record ScheduleHolidayCalendarDto( + Guid CalendarId, + string CalendarName, + string Mode ); diff --git a/src/Werkr.Common/Models/Holidays/ScheduleHolidayDatesResponse.cs b/src/Werkr.Common/Models/Holidays/ScheduleHolidayDatesResponse.cs new file mode 100644 index 0000000..3777fe9 --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/ScheduleHolidayDatesResponse.cs @@ -0,0 +1,8 @@ +namespace Werkr.Common.Models.Holidays; + +/// Per-schedule holiday dates response. +public sealed record ScheduleHolidayDatesResponse( + Guid ScheduleId, + Guid CalendarId, + string Mode, + IReadOnlyList Dates ); diff --git a/src/Werkr.Common/Models/Holidays/SuppressedOccurrenceDto.cs b/src/Werkr.Common/Models/Holidays/SuppressedOccurrenceDto.cs new file mode 100644 index 0000000..b5a4c9b --- /dev/null +++ b/src/Werkr.Common/Models/Holidays/SuppressedOccurrenceDto.cs @@ -0,0 +1,7 @@ +namespace Werkr.Common.Models.Holidays; + +/// Suppressed occurrence detail. +public sealed record SuppressedOccurrenceDto( + DateTime UtcTime, + string HolidayName, + string Reason ); diff --git a/src/Werkr.Common/Models/JobDto.cs b/src/Werkr.Common/Models/JobDto.cs new file mode 100644 index 0000000..8c918bb --- /dev/null +++ b/src/Werkr.Common/Models/JobDto.cs @@ -0,0 +1,15 @@ +namespace Werkr.Common.Models; + +/// Response DTO for a job record. +public sealed record JobDto( + Guid Id, + long TaskId, + bool Success, + int? ExitCode, + string ErrorCategory, + double RuntimeSeconds, + DateTime StartTime, + DateTime? EndTime, + Guid? AgentConnectionId, + string? Output, + string? OutputPath ); diff --git a/src/Werkr.Common/Models/JobListDto.cs b/src/Werkr.Common/Models/JobListDto.cs new file mode 100644 index 0000000..401d67e --- /dev/null +++ b/src/Werkr.Common/Models/JobListDto.cs @@ -0,0 +1,14 @@ +namespace Werkr.Common.Models; + +/// Summary DTO for job list views. +public sealed record JobListDto( + Guid Id, + long TaskId, + bool Success, + double RuntimeSeconds, + DateTime StartTime, + string ErrorCategory, + string? TaskName = null, + Guid? AgentConnectionId = null, + string? AgentName = null, + DateTime? EndTime = null ); diff --git a/src/Werkr.Common/Models/MonthlyRecurrenceDto.cs b/src/Werkr.Common/Models/MonthlyRecurrenceDto.cs new file mode 100644 index 0000000..6058029 --- /dev/null +++ b/src/Werkr.Common/Models/MonthlyRecurrenceDto.cs @@ -0,0 +1,12 @@ +namespace Werkr.Common.Models; + +/// DTO for MonthlyRecurrence. +/// Specific day-of-month values (null for week+day mode). +/// Flags value indicating which months (see Werkr.Data.Calendar.Enums.MonthsOfYear). +/// Week within month flags value (null for day-number mode, see Werkr.Data.Calendar.Enums.WeekNumberWithinMonth). +/// Day-of-week flags value (null for day-number mode, see Werkr.Data.Calendar.Enums.DaysOfWeek). +public sealed record MonthlyRecurrenceDto( + int[]? DayNumbers, + int MonthsOfYear, + int? WeekNumber, + int? DaysOfWeek ); diff --git a/src/Werkr.Common/Models/NotifyUrlChangeRequest.cs b/src/Werkr.Common/Models/NotifyUrlChangeRequest.cs new file mode 100644 index 0000000..bbd2c27 --- /dev/null +++ b/src/Werkr.Common/Models/NotifyUrlChangeRequest.cs @@ -0,0 +1,4 @@ +namespace Werkr.Common.Models; + +/// Request body for notifying agents of a server URL change. +public sealed record NotifyUrlChangeRequest( string NewServerUrl ); diff --git a/src/Werkr.Common/Models/NotifyUrlChangeResponse.cs b/src/Werkr.Common/Models/NotifyUrlChangeResponse.cs new file mode 100644 index 0000000..7c95bda --- /dev/null +++ b/src/Werkr.Common/Models/NotifyUrlChangeResponse.cs @@ -0,0 +1,7 @@ +namespace Werkr.Common.Models; + +/// Response body from a server URL change notification operation. +public sealed record NotifyUrlChangeResponse( + int Notified, + int Failed, + IReadOnlyList FailedAgents ); diff --git a/src/Werkr.Common/Models/OccurrencePreviewResponse.cs b/src/Werkr.Common/Models/OccurrencePreviewResponse.cs new file mode 100644 index 0000000..9a6930e --- /dev/null +++ b/src/Werkr.Common/Models/OccurrencePreviewResponse.cs @@ -0,0 +1,12 @@ +using Werkr.Common.Models.Holidays; + +namespace Werkr.Common.Models; + +/// Response DTO for occurrence preview. +public sealed record OccurrencePreviewResponse( + Guid ScheduleId, + DateTime WindowEnd, + IReadOnlyList Occurrences, + IReadOnlyList? Suppressed = null, + string? HolidayCalendarName = null, + string? HolidayCalendarMode = null ); diff --git a/src/Werkr.Common/Models/OperatorOutputLine.cs b/src/Werkr.Common/Models/OperatorOutputLine.cs new file mode 100644 index 0000000..187ae0b --- /dev/null +++ b/src/Werkr.Common/Models/OperatorOutputLine.cs @@ -0,0 +1,13 @@ +namespace Werkr.Common.Models; + +/// +/// A single line of operator output from a command or script execution. +/// Mirrors the shape of OperatorOutput in the Core project for JSON transport. +/// +/// The severity level (e.g., Trace, Debug, Information, Warning, Error). +/// The output content. +/// ISO 8601 UTC timestamp. +public sealed record OperatorOutputLine( + string LogLevel, + string Message, + string Timestamp ); diff --git a/src/Werkr.Common/Models/OperatorType.cs b/src/Werkr.Common/Models/OperatorType.cs new file mode 100644 index 0000000..295b03f --- /dev/null +++ b/src/Werkr.Common/Models/OperatorType.cs @@ -0,0 +1,13 @@ +namespace Werkr.Common.Models; + +/// Operator types available for task execution. +public enum OperatorType { + /// PowerShell operator. + PowerShell = 0, + + /// System shell operator (cmd/bash). + SystemShell = 1, + + /// Built-in action operator. + Action = 2, +} diff --git a/src/Werkr.Common/Models/PathType.cs b/src/Werkr.Common/Models/PathType.cs new file mode 100644 index 0000000..56af9ca --- /dev/null +++ b/src/Werkr.Common/Models/PathType.cs @@ -0,0 +1,16 @@ +namespace Werkr.Common.Models; + +/// +/// Discriminator for the TestExists action to specify +/// whether to assert file existence, directory existence, or either. +/// +public enum PathType { + /// Assert only file existence. + File = 0, + + /// Assert only directory existence. + Directory = 1, + + /// Assert either file or directory exists (default). + Any = 2, +} diff --git a/src/Werkr.Common/Models/RegistrationGenerateRequest.cs b/src/Werkr.Common/Models/RegistrationGenerateRequest.cs new file mode 100644 index 0000000..c04d3e7 --- /dev/null +++ b/src/Werkr.Common/Models/RegistrationGenerateRequest.cs @@ -0,0 +1,12 @@ +namespace Werkr.Common.Models; + +/// Request body for POST /api/registration/generate. +/// Admin-assigned label for the Agent connection. +/// Password used to encrypt the registration bundle. +/// Bundle expiration in minutes (null = default 24 hours, 0 = never). +/// Optional tags to assign to the agent upon registration completion. +public sealed record RegistrationGenerateRequest( + string ConnectionName, + string Password, + int? ExpirationMinutes, + string[]? Tags = null ); diff --git a/src/Werkr.Common/Models/RegistrationGenerateResponse.cs b/src/Werkr.Common/Models/RegistrationGenerateResponse.cs new file mode 100644 index 0000000..57194fe --- /dev/null +++ b/src/Werkr.Common/Models/RegistrationGenerateResponse.cs @@ -0,0 +1,10 @@ +namespace Werkr.Common.Models; + +/// Response body for POST /api/registration/generate. +/// True when the bundle was generated successfully. +/// The encrypted bundle payload. +/// Optional informational or error message. +public sealed record RegistrationGenerateResponse( + bool Success, + string? EncryptedBundle, + string? Message ); diff --git a/src/Werkr.Common/Models/RegistrationStatus.cs b/src/Werkr.Common/Models/RegistrationStatus.cs new file mode 100644 index 0000000..71d1233 --- /dev/null +++ b/src/Werkr.Common/Models/RegistrationStatus.cs @@ -0,0 +1,16 @@ +namespace Werkr.Common.Models; + +/// Status of a pending registration bundle. +public enum RegistrationStatus { + /// Bundle has been generated and is awaiting completion. + Pending = 0, + + /// Registration has been completed successfully. + Completed = 1, + + /// Bundle has expired without being used. + Expired = 2, + + /// Bundle has been manually revoked by an admin. + Revoked = 3, +} diff --git a/src/Werkr.Common/Models/RepeatOptionsDto.cs b/src/Werkr.Common/Models/RepeatOptionsDto.cs new file mode 100644 index 0000000..74f026e --- /dev/null +++ b/src/Werkr.Common/Models/RepeatOptionsDto.cs @@ -0,0 +1,6 @@ +namespace Werkr.Common.Models; + +/// DTO for ScheduleRepeatOptions. +/// Interval between repeats. +/// Duration over which repeats occur. +public sealed record RepeatOptionsDto( int RepeatIntervalMinutes, int RepeatDurationMinutes ); diff --git a/src/Werkr.Common/Models/ScheduleCreateRequest.cs b/src/Werkr.Common/Models/ScheduleCreateRequest.cs new file mode 100644 index 0000000..6c5abd5 --- /dev/null +++ b/src/Werkr.Common/Models/ScheduleCreateRequest.cs @@ -0,0 +1,12 @@ +namespace Werkr.Common.Models; + +/// Request DTO for creating a new schedule. +public sealed record ScheduleCreateRequest( + string Name, + long StopTaskAfterMinutes, + StartDateTimeDto StartDateTime, + ExpirationDateTimeDto? Expiration, + DailyRecurrenceDto? DailyRecurrence, + WeeklyRecurrenceDto? WeeklyRecurrence, + MonthlyRecurrenceDto? MonthlyRecurrence, + RepeatOptionsDto? RepeatOptions ); diff --git a/src/Werkr.Common/Models/ScheduleDto.cs b/src/Werkr.Common/Models/ScheduleDto.cs new file mode 100644 index 0000000..4b6721f --- /dev/null +++ b/src/Werkr.Common/Models/ScheduleDto.cs @@ -0,0 +1,13 @@ +namespace Werkr.Common.Models; + +/// Response DTO for a full schedule composite. +public sealed record ScheduleDto( + Guid Id, + string Name, + long StopTaskAfterMinutes, + StartDateTimeDto? StartDateTime, + ExpirationDateTimeDto? Expiration, + DailyRecurrenceDto? DailyRecurrence, + WeeklyRecurrenceDto? WeeklyRecurrence, + MonthlyRecurrenceDto? MonthlyRecurrence, + RepeatOptionsDto? RepeatOptions ); diff --git a/src/Werkr.Common/Models/ScheduleUpdateRequest.cs b/src/Werkr.Common/Models/ScheduleUpdateRequest.cs new file mode 100644 index 0000000..29cbba5 --- /dev/null +++ b/src/Werkr.Common/Models/ScheduleUpdateRequest.cs @@ -0,0 +1,12 @@ +namespace Werkr.Common.Models; + +/// Request DTO for updating an existing schedule. +public sealed record ScheduleUpdateRequest( + string Name, + long StopTaskAfterMinutes, + StartDateTimeDto StartDateTime, + ExpirationDateTimeDto? Expiration, + DailyRecurrenceDto? DailyRecurrence, + WeeklyRecurrenceDto? WeeklyRecurrence, + MonthlyRecurrenceDto? MonthlyRecurrence, + RepeatOptionsDto? RepeatOptions ); diff --git a/src/Werkr.Common/Models/StartDateTimeDto.cs b/src/Werkr.Common/Models/StartDateTimeDto.cs new file mode 100644 index 0000000..2b60940 --- /dev/null +++ b/src/Werkr.Common/Models/StartDateTimeDto.cs @@ -0,0 +1,7 @@ +namespace Werkr.Common.Models; + +/// DTO for StartDateTimeInfo. +/// Start date. +/// Start time. +/// IANA or Windows timezone identifier. +public sealed record StartDateTimeDto( DateOnly Date, TimeOnly Time, string TimeZoneId ); diff --git a/src/Werkr.Common/Models/StepDependencyDto.cs b/src/Werkr.Common/Models/StepDependencyDto.cs new file mode 100644 index 0000000..e74fa39 --- /dev/null +++ b/src/Werkr.Common/Models/StepDependencyDto.cs @@ -0,0 +1,4 @@ +namespace Werkr.Common.Models; + +/// Response DTO for a step dependency. +public sealed record StepDependencyDto( long StepId, long DependsOnStepId ); diff --git a/src/Werkr.Common/Models/StepDependencyRequest.cs b/src/Werkr.Common/Models/StepDependencyRequest.cs new file mode 100644 index 0000000..769da8f --- /dev/null +++ b/src/Werkr.Common/Models/StepDependencyRequest.cs @@ -0,0 +1,4 @@ +namespace Werkr.Common.Models; + +/// Request DTO for creating a step dependency. +public sealed record StepDependencyRequest( long DependsOnStepId ); diff --git a/src/Werkr.Common/Models/TaskCreateRequest.cs b/src/Werkr.Common/Models/TaskCreateRequest.cs new file mode 100644 index 0000000..a93e28a --- /dev/null +++ b/src/Werkr.Common/Models/TaskCreateRequest.cs @@ -0,0 +1,17 @@ +namespace Werkr.Common.Models; + +/// Request DTO for creating a new task. +public sealed record TaskCreateRequest( + string Name, + string? Description, + string ActionType, + string Content, + string[]? Arguments, + string[] TargetTags, + bool Enabled = true, + long? TimeoutMinutes = null, + string? SuccessCriteria = null, + Guid? ScheduleId = null, + long? WorkflowId = null, + string? ActionSubType = null, + string? ActionParameters = null ); diff --git a/src/Werkr.Common/Models/TaskDto.cs b/src/Werkr.Common/Models/TaskDto.cs new file mode 100644 index 0000000..3751749 --- /dev/null +++ b/src/Werkr.Common/Models/TaskDto.cs @@ -0,0 +1,20 @@ +namespace Werkr.Common.Models; + +/// Response DTO for a task. +public sealed record TaskDto( + long Id, + string Name, + string Description, + string ActionType, + string Content, + string[]? Arguments, + string[] TargetTags, + bool Enabled, + long? TimeoutMinutes, + int SyncIntervalMinutes, + string? SuccessCriteria, + string EffectiveSuccessCriteria, + Guid? ScheduleId, + long? WorkflowId, + string? ActionSubType = null, + string? ActionParameters = null ); diff --git a/src/Werkr.Common/Models/TaskRunRequest.cs b/src/Werkr.Common/Models/TaskRunRequest.cs new file mode 100644 index 0000000..e8916de --- /dev/null +++ b/src/Werkr.Common/Models/TaskRunRequest.cs @@ -0,0 +1,4 @@ +namespace Werkr.Common.Models; + +/// Request DTO for triggering an ad-hoc task run. +public sealed record TaskRunRequest( Guid? AgentConnectionId = null ); diff --git a/src/Werkr.Common/Models/TaskSetEnabledRequest.cs b/src/Werkr.Common/Models/TaskSetEnabledRequest.cs new file mode 100644 index 0000000..1358961 --- /dev/null +++ b/src/Werkr.Common/Models/TaskSetEnabledRequest.cs @@ -0,0 +1,4 @@ +namespace Werkr.Common.Models; + +/// Request DTO for toggling task enabled state. +public sealed record TaskSetEnabledRequest( bool Enabled ); diff --git a/src/Werkr.Common/Models/TaskUpdateRequest.cs b/src/Werkr.Common/Models/TaskUpdateRequest.cs new file mode 100644 index 0000000..d7a3459 --- /dev/null +++ b/src/Werkr.Common/Models/TaskUpdateRequest.cs @@ -0,0 +1,17 @@ +namespace Werkr.Common.Models; + +/// Request DTO for updating an existing task. +public sealed record TaskUpdateRequest( + string Name, + string? Description, + string ActionType, + string Content, + string[]? Arguments, + string[] TargetTags, + bool Enabled = true, + long? TimeoutMinutes = null, + string? SuccessCriteria = null, + Guid? ScheduleId = null, + long? WorkflowId = null, + string? ActionSubType = null, + string? ActionParameters = null ); diff --git a/src/Werkr.Common/Models/TokenRequest.cs b/src/Werkr.Common/Models/TokenRequest.cs new file mode 100644 index 0000000..0d4f323 --- /dev/null +++ b/src/Werkr.Common/Models/TokenRequest.cs @@ -0,0 +1,4 @@ +namespace Werkr.Common.Models; + +/// Request body for exchanging an API key for a JWT bearer token. +public sealed record TokenRequest( string ApiKey ); diff --git a/src/Werkr.Common/Models/TokenResponse.cs b/src/Werkr.Common/Models/TokenResponse.cs new file mode 100644 index 0000000..d076d20 --- /dev/null +++ b/src/Werkr.Common/Models/TokenResponse.cs @@ -0,0 +1,6 @@ +namespace Werkr.Common.Models; + +/// Response body from a successful token exchange. +public sealed record TokenResponse( + string Token, + DateTime ExpiresUtc ); diff --git a/src/Werkr.Common/Models/UpdateAgentRequest.cs b/src/Werkr.Common/Models/UpdateAgentRequest.cs new file mode 100644 index 0000000..c59602f --- /dev/null +++ b/src/Werkr.Common/Models/UpdateAgentRequest.cs @@ -0,0 +1,6 @@ +namespace Werkr.Common.Models; + +/// Request body for updating an agent connection. +/// New display name (optional if RemoteUrl is provided). +/// New gRPC endpoint URL for the agent (optional if ConnectionName is provided). +public sealed record UpdateAgentRequest( string? ConnectionName, string? RemoteUrl ); diff --git a/src/Werkr.Common/Models/UpdateAgentStatusRequest.cs b/src/Werkr.Common/Models/UpdateAgentStatusRequest.cs new file mode 100644 index 0000000..cd36097 --- /dev/null +++ b/src/Werkr.Common/Models/UpdateAgentStatusRequest.cs @@ -0,0 +1,4 @@ +namespace Werkr.Common.Models; + +/// Request body for updating an agent's connection status. +public sealed record UpdateAgentStatusRequest( string Status ); diff --git a/src/Werkr.Common/Models/UpdateAgentTagsRequest.cs b/src/Werkr.Common/Models/UpdateAgentTagsRequest.cs new file mode 100644 index 0000000..c3a7794 --- /dev/null +++ b/src/Werkr.Common/Models/UpdateAgentTagsRequest.cs @@ -0,0 +1,4 @@ +namespace Werkr.Common.Models; + +/// Request DTO for updating agent tags. +public sealed record UpdateAgentTagsRequest( string[] Tags ); diff --git a/src/Werkr.Common/Models/WeeklyRecurrenceDto.cs b/src/Werkr.Common/Models/WeeklyRecurrenceDto.cs new file mode 100644 index 0000000..86b78b2 --- /dev/null +++ b/src/Werkr.Common/Models/WeeklyRecurrenceDto.cs @@ -0,0 +1,6 @@ +namespace Werkr.Common.Models; + +/// DTO for WeeklyRecurrence. +/// Recur every N weeks. +/// Flags value indicating which days to recur on (see Werkr.Data.Calendar.Enums.DaysOfWeek). +public sealed record WeeklyRecurrenceDto( int WeekInterval, int DaysOfWeek ); diff --git a/src/Werkr.Common/Models/WorkflowCreateRequest.cs b/src/Werkr.Common/Models/WorkflowCreateRequest.cs new file mode 100644 index 0000000..3617185 --- /dev/null +++ b/src/Werkr.Common/Models/WorkflowCreateRequest.cs @@ -0,0 +1,8 @@ +namespace Werkr.Common.Models; + +/// Request DTO for creating a new workflow. +public sealed record WorkflowCreateRequest( + string Name, + string? Description = null, + bool Enabled = true, + Guid? ScheduleId = null ); diff --git a/src/Werkr.Common/Models/WorkflowDto.cs b/src/Werkr.Common/Models/WorkflowDto.cs new file mode 100644 index 0000000..5576f33 --- /dev/null +++ b/src/Werkr.Common/Models/WorkflowDto.cs @@ -0,0 +1,10 @@ +namespace Werkr.Common.Models; + +/// Response DTO for a workflow. +public sealed record WorkflowDto( + long Id, + string Name, + string Description, + bool Enabled, + Guid? ScheduleId, + IReadOnlyList Steps ); diff --git a/src/Werkr.Common/Models/WorkflowRunDetailDto.cs b/src/Werkr.Common/Models/WorkflowRunDetailDto.cs new file mode 100644 index 0000000..369f5d4 --- /dev/null +++ b/src/Werkr.Common/Models/WorkflowRunDetailDto.cs @@ -0,0 +1,10 @@ +namespace Werkr.Common.Models; + +/// Response DTO for a workflow run with job details. +public sealed record WorkflowRunDetailDto( + Guid Id, + long WorkflowId, + DateTime StartTime, + DateTime? EndTime, + string Status, + IReadOnlyList Jobs ); diff --git a/src/Werkr.Common/Models/WorkflowRunDto.cs b/src/Werkr.Common/Models/WorkflowRunDto.cs new file mode 100644 index 0000000..d30104c --- /dev/null +++ b/src/Werkr.Common/Models/WorkflowRunDto.cs @@ -0,0 +1,9 @@ +namespace Werkr.Common.Models; + +/// Response DTO for a workflow run. +public sealed record WorkflowRunDto( + Guid Id, + long WorkflowId, + DateTime StartTime, + DateTime? EndTime, + string Status ); diff --git a/src/Werkr.Common/Models/WorkflowRunRequest.cs b/src/Werkr.Common/Models/WorkflowRunRequest.cs new file mode 100644 index 0000000..18c917a --- /dev/null +++ b/src/Werkr.Common/Models/WorkflowRunRequest.cs @@ -0,0 +1,4 @@ +namespace Werkr.Common.Models; + +/// Request DTO for triggering a workflow run. +public sealed record WorkflowRunRequest( ); diff --git a/src/Werkr.Common/Models/WorkflowSetEnabledRequest.cs b/src/Werkr.Common/Models/WorkflowSetEnabledRequest.cs new file mode 100644 index 0000000..e70e24f --- /dev/null +++ b/src/Werkr.Common/Models/WorkflowSetEnabledRequest.cs @@ -0,0 +1,4 @@ +namespace Werkr.Common.Models; + +/// Request DTO for toggling workflow enabled state. +public sealed record WorkflowSetEnabledRequest( bool Enabled ); diff --git a/src/Werkr.Common/Models/WorkflowStepCreateRequest.cs b/src/Werkr.Common/Models/WorkflowStepCreateRequest.cs new file mode 100644 index 0000000..1846ac6 --- /dev/null +++ b/src/Werkr.Common/Models/WorkflowStepCreateRequest.cs @@ -0,0 +1,11 @@ +namespace Werkr.Common.Models; + +/// Request DTO for creating a workflow step. +public sealed record WorkflowStepCreateRequest( + long TaskId, + int Order = 0, + string ControlStatement = "Sequential", + string? ConditionExpression = null, + int MaxIterations = 100, + Guid? AgentConnectionIdOverride = null, + string DependencyMode = "All" ); diff --git a/src/Werkr.Common/Models/WorkflowStepDto.cs b/src/Werkr.Common/Models/WorkflowStepDto.cs new file mode 100644 index 0000000..816d54f --- /dev/null +++ b/src/Werkr.Common/Models/WorkflowStepDto.cs @@ -0,0 +1,14 @@ +namespace Werkr.Common.Models; + +/// Response DTO for a workflow step. +public sealed record WorkflowStepDto( + long Id, + long WorkflowId, + long TaskId, + int Order, + string ControlStatement, + string? ConditionExpression, + int MaxIterations, + Guid? AgentConnectionIdOverride, + string DependencyMode, + IReadOnlyList Dependencies ); diff --git a/src/Werkr.Common/Models/WorkflowStepStatusUpdate.cs b/src/Werkr.Common/Models/WorkflowStepStatusUpdate.cs new file mode 100644 index 0000000..23793a1 --- /dev/null +++ b/src/Werkr.Common/Models/WorkflowStepStatusUpdate.cs @@ -0,0 +1,10 @@ +namespace Werkr.Common.Models; + +/// Real-time status update for a single workflow step during execution. +public sealed record WorkflowStepStatusUpdate( + Guid RunId, + long StepId, + string StepName, + string Status, + DateTime Timestamp, + string? ErrorMessage ); diff --git a/src/Werkr.Common/Models/WorkflowStepUpdateRequest.cs b/src/Werkr.Common/Models/WorkflowStepUpdateRequest.cs new file mode 100644 index 0000000..6e9a80f --- /dev/null +++ b/src/Werkr.Common/Models/WorkflowStepUpdateRequest.cs @@ -0,0 +1,10 @@ +namespace Werkr.Common.Models; + +/// Request DTO for updating a workflow step. +public sealed record WorkflowStepUpdateRequest( + int Order = 0, + string ControlStatement = "Sequential", + string? ConditionExpression = null, + int MaxIterations = 100, + Guid? AgentConnectionIdOverride = null, + string DependencyMode = "All" ); diff --git a/src/Werkr.Common/Models/WorkflowUpdateRequest.cs b/src/Werkr.Common/Models/WorkflowUpdateRequest.cs new file mode 100644 index 0000000..cbfd5cd --- /dev/null +++ b/src/Werkr.Common/Models/WorkflowUpdateRequest.cs @@ -0,0 +1,8 @@ +namespace Werkr.Common.Models; + +/// Request DTO for updating an existing workflow. +public sealed record WorkflowUpdateRequest( + string Name, + string? Description = null, + bool Enabled = true, + Guid? ScheduleId = null ); diff --git a/src/Werkr.Common/Protos/ConnectionManagement.proto b/src/Werkr.Common/Protos/ConnectionManagement.proto new file mode 100644 index 0000000..019cd88 --- /dev/null +++ b/src/Werkr.Common/Protos/ConnectionManagement.proto @@ -0,0 +1,71 @@ +syntax = "proto3"; +option csharp_namespace = "Werkr.Common.Protos"; +package werkr.common.protos; + +import "EncryptedEnvelope.proto"; + +// Server pushes connection management commands to agents (Agent hosts this service). +// All RPCs use EncryptedEnvelope. Inner payload types defined below. +service ConnectionManagement { + // Request envelope contains NotifyServerUrlChangedRequest; response contains NotifyServerUrlChangedResponse. + rpc NotifyServerUrlChanged (EncryptedEnvelope) returns (EncryptedEnvelope); + + // Application-level health check. Request envelope contains HeartbeatRequest; + // response contains HeartbeatResponse. Replaces Grpc.Health.V1 for agent health probing. + rpc Heartbeat (EncryptedEnvelope) returns (EncryptedEnvelope); + + // Key rotation. Request envelope contains RotateSharedKeyRequest; + // response contains RotateSharedKeyResponse. Uses current PSK for envelope encryption; + // new key is additionally RSA-encrypted inside for defense-in-depth. + rpc RotateSharedKey (EncryptedEnvelope) returns (EncryptedEnvelope); +} + +// --- NotifyServerUrlChanged --- + +message NotifyServerUrlChangedRequest { + // The new server URL the agent should use for outbound connections. + string new_server_url = 1; +} + +message NotifyServerUrlChangedResponse { + // Whether the agent acknowledged and applied the new URL. + bool acknowledged = 1; + // Optional status message. + string message = 2; +} + +// --- Heartbeat --- + +message HeartbeatRequest { + // Agent's assembly version string. + string agent_version = 1; + // Seconds since agent process started. + int64 uptime_seconds = 2; + // Number of active schedules currently loaded. + int32 active_schedule_count = 3; + // Free-form status message. + string status_message = 4; +} + +message HeartbeatResponse { + // Whether the server acknowledged the heartbeat. + bool acknowledged = 1; + // Server's assembly version string. + string server_version = 2; +} + +// --- RotateSharedKey --- + +message RotateSharedKeyRequest { + // New AES-256 key encrypted with the Agent's RSA public key (defense-in-depth). + bytes rsa_encrypted_new_key = 1; + // Identifier for the new key (UUID). + string new_key_id = 2; +} + +message RotateSharedKeyResponse { + // Whether the agent accepted and activated the new key. + bool success = 1; + // The key ID now active on the agent. + string active_key_id = 2; +} diff --git a/src/Werkr.Common/Protos/EncryptedEnvelope.proto b/src/Werkr.Common/Protos/EncryptedEnvelope.proto new file mode 100644 index 0000000..807b5fd --- /dev/null +++ b/src/Werkr.Common/Protos/EncryptedEnvelope.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; +option csharp_namespace = "Werkr.Common.Protos"; +package werkr.common.protos; + +// Encrypted wrapper for all gRPC payloads. +// The inner payload is a serialized protobuf message encrypted with AES-256-GCM +// using the connection's SharedKey established during agent registration. +// Registration.proto is the sole exception — it uses hybrid RSA+AES encryption +// to establish the SharedKey and cannot use EncryptedEnvelope. +message EncryptedEnvelope { + // AES-256-GCM encrypted payload (serialized protobuf message bytes). + bytes ciphertext = 1; + + // 12-byte initialization vector (nonce) for AES-GCM. + bytes iv = 2; + + // 16-byte GCM authentication tag. + bytes auth_tag = 3; + + // Identifies which SharedKey was used for encryption. + // Supports key rotation grace period — the receiver uses this + // to determine whether to decrypt with the current or previous key. + string key_id = 4; +} diff --git a/src/Werkr.Common/Protos/JobReport.proto b/src/Werkr.Common/Protos/JobReport.proto new file mode 100644 index 0000000..78e71dd --- /dev/null +++ b/src/Werkr.Common/Protos/JobReport.proto @@ -0,0 +1,46 @@ +syntax = "proto3"; +option csharp_namespace = "Werkr.Common.Protos"; +package werkr.common.protos; + +import "EncryptedEnvelope.proto"; + +// Agent reports completed job results to the Server (API hosts this service). +// All RPCs use EncryptedEnvelope. Inner payload types defined below. +service JobReporting { + // Request envelope contains JobResultRequest; response contains JobResultResponse. + rpc ReportJobResult (EncryptedEnvelope) returns (EncryptedEnvelope); +} + +message JobResultRequest { + // Agent's connection ID for authentication. + string connection_id = 1; + // The task ID that was executed. + int64 task_id = 2; + // Snapshot of the task content at execution time. + string task_snapshot = 3; + // Total runtime in seconds. + double runtime_seconds = 4; + // Job start time in ISO 8601 UTC format. + string start_time = 5; + // Job end time in ISO 8601 UTC format. + string end_time = 6; + // Whether the job succeeded. + bool success = 7; + // Process/shell exit code. + int32 exit_code = 8; + // Error category enum value. + int32 error_category = 9; + // Relative path to the full output log file on the agent's local disk. + string output_path = 10; + // Optional workflow run ID if this job was part of a workflow. + string workflow_run_id = 11; + // Tail preview of the output (last N lines) for quick display without fetching the full log. + string output_preview = 12; +} + +message JobResultResponse { + // Whether the server accepted the result. + bool accepted = 1; + // The assigned job ID on the server. + string job_id = 2; +} diff --git a/src/Werkr.Common/Protos/OutputFetch.proto b/src/Werkr.Common/Protos/OutputFetch.proto new file mode 100644 index 0000000..7c30ade --- /dev/null +++ b/src/Werkr.Common/Protos/OutputFetch.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; +option csharp_namespace = "Werkr.Common.Protos"; +package werkr.common.protos; + +import "EncryptedEnvelope.proto"; + +// On-demand retrieval of job output from the agent's local disk (Agent hosts this service). +// All RPCs use EncryptedEnvelope. Inner payload types defined below. +service OutputFetch { + // Request envelope contains GetJobOutputRequest; response contains GetJobOutputResponse. + rpc GetJobOutput (EncryptedEnvelope) returns (EncryptedEnvelope); +} + +message GetJobOutputRequest { + // The job ID whose output to retrieve. + string job_id = 1; + // The output file path on the agent (relative to the job output directory). + string output_path = 2; +} + +message GetJobOutputResponse { + // Whether the output file was found and readable. + bool found = 1; + // The full contents of the output file. + string content = 2; + // Error message if the file was not found or not readable. + string error = 3; +} diff --git a/src/Werkr.Common/Protos/ScheduleInvalidation.proto b/src/Werkr.Common/Protos/ScheduleInvalidation.proto new file mode 100644 index 0000000..175133b --- /dev/null +++ b/src/Werkr.Common/Protos/ScheduleInvalidation.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; +option csharp_namespace = "Werkr.Common.Protos"; +package werkr.common.protos; + +import "EncryptedEnvelope.proto"; + +// Server pushes schedule invalidation notifications to agents (Agent hosts this service). +// All RPCs use EncryptedEnvelope. Inner payload types defined below. +service ScheduleInvalidation { + // Request envelope contains InvalidateScheduleRequest; response contains InvalidateScheduleResponse. + rpc InvalidateSchedule (EncryptedEnvelope) returns (EncryptedEnvelope); +} + +// Inner payload messages — serialized, encrypted, then placed inside EncryptedEnvelope. +message InvalidateScheduleRequest { + // The schedule ID that was modified or deleted. + string schedule_id = 1; +} + +message InvalidateScheduleResponse { + // Whether the agent acknowledged the invalidation. + bool acknowledged = 1; +} diff --git a/src/Werkr.Common/Protos/ScheduleSync.proto b/src/Werkr.Common/Protos/ScheduleSync.proto new file mode 100644 index 0000000..6f88ecf --- /dev/null +++ b/src/Werkr.Common/Protos/ScheduleSync.proto @@ -0,0 +1,149 @@ +syntax = "proto3"; +option csharp_namespace = "Werkr.Common.Protos"; +package werkr.common.protos; + +import "EncryptedEnvelope.proto"; + +// Agent pulls assigned schedules from the Server (API hosts this service). +// All RPCs use EncryptedEnvelope. Inner payload types defined below. +service ScheduleSync { + // Request envelope contains AgentScheduleRequest; response contains AgentScheduleResponse. + rpc GetAssignedSchedules (EncryptedEnvelope) returns (EncryptedEnvelope); + // Request envelope contains GetBulkScheduleHolidayDatesRequest; response contains GetBulkScheduleHolidayDatesResponse. + rpc GetBulkScheduleHolidayDates (EncryptedEnvelope) returns (EncryptedEnvelope); + // Request envelope contains SubmitAuditLogRequest; response contains SubmitAuditLogResponse. + rpc SubmitAuditLog (EncryptedEnvelope) returns (EncryptedEnvelope); +} + +message AgentScheduleRequest { + // Agent's tags for matching against task TargetTags. + repeated string tags = 1; + // Agent's connection ID for authentication. + string connection_id = 2; +} + +message AgentScheduleResponse { + repeated ScheduledTaskDefinition tasks = 1; + repeated ScheduledWorkflowDefinition workflows = 2; +} + +message ScheduledTaskDefinition { + int64 task_id = 1; + string name = 2; + int32 action_type = 3; + string content = 4; + int64 timeout_minutes = 5; + int32 sync_interval_minutes = 6; + ScheduleDefinition schedule = 7; + string success_criteria = 8; + repeated string arguments = 9; + string action_sub_type = 10; + string action_parameters_json = 11; +} + +message ScheduleDefinition { + string schedule_id = 1; + string start_date = 2; + string start_time = 3; + string time_zone_id = 4; + string expiration_date = 5; + string expiration_time = 6; + string expiration_time_zone_id = 7; + int64 stop_task_after_minutes = 8; + DailyRecurrenceDef daily = 9; + WeeklyRecurrenceDef weekly = 10; + MonthlyRecurrenceDef monthly = 11; + RepeatOptionsDef repeat = 12; + bool has_holiday_calendar = 13; + string holiday_calendar_mode = 14; +} + +message DailyRecurrenceDef { + int32 day_interval = 1; +} + +message WeeklyRecurrenceDef { + int32 week_interval = 1; + int32 recurrence_days = 2; +} + +message MonthlyRecurrenceDef { + repeated int32 day_numbers = 1; + int32 months_of_year = 2; + int32 week_number = 3; + int32 days_of_week = 4; +} + +message RepeatOptionsDef { + int32 interval_minutes = 1; + int32 duration_minutes = 2; +} + +message ScheduledWorkflowDefinition { + int64 workflow_id = 1; + string name = 2; + repeated ScheduledWorkflowStepDef steps = 3; + ScheduleDefinition schedule = 4; +} + +message ScheduledWorkflowStepDef { + int64 step_id = 1; + int64 task_id = 2; + int32 order = 3; + repeated int64 depends_on_step_ids = 4; + int32 control_statement = 5; + string condition_expression = 6; + int32 max_iterations = 7; + ScheduledTaskDefinition task = 8; + string agent_connection_id_override = 9; + int32 dependency_mode = 10; +} + +// ── Bulk Holiday Date Fetch ── + +message GetBulkScheduleHolidayDatesRequest { + repeated ScheduleHolidayDateQuery queries = 1; + string start_date = 2; + string end_date = 3; +} + +message ScheduleHolidayDateQuery { + string schedule_id = 1; +} + +message GetBulkScheduleHolidayDatesResponse { + repeated ScheduleHolidayDateResult results = 1; +} + +message ScheduleHolidayDateResult { + string schedule_id = 1; + string calendar_id = 2; + string mode = 3; + repeated HolidayDateMessage dates = 4; +} + +message HolidayDateMessage { + string date = 1; + string name = 2; + int32 year = 3; + string window_start = 4; + string window_end = 5; + string window_time_zone_id = 6; +} + +// ── Audit Log Submission ── + +message SubmitAuditLogRequest { + string schedule_id = 1; + repeated AuditLogEntry entries = 2; +} + +message AuditLogEntry { + string occurrence_utc = 1; + string holiday_name = 2; + string reason = 3; +} + +message SubmitAuditLogResponse { + int32 accepted_count = 1; +} diff --git a/src/Werkr.Common/Protos/WorkflowExecution.proto b/src/Werkr.Common/Protos/WorkflowExecution.proto new file mode 100644 index 0000000..8d7f1ad --- /dev/null +++ b/src/Werkr.Common/Protos/WorkflowExecution.proto @@ -0,0 +1,28 @@ +syntax = "proto3"; +option csharp_namespace = "Werkr.Common.Protos"; +package werkr.common.protos; + +import "EncryptedEnvelope.proto"; + +// Agent requests the server to orchestrate a workflow execution (API hosts this service). +// All RPCs use EncryptedEnvelope. Inner payload types defined below. +service WorkflowExecution { + // Request envelope contains WorkflowRunGrpcRequest; response contains WorkflowRunGrpcResponse. + rpc RequestWorkflowRun (EncryptedEnvelope) returns (EncryptedEnvelope); +} + +message WorkflowRunGrpcRequest { + // Agent's connection ID for authentication. + string connection_id = 1; + // The workflow ID to execute. + int64 workflow_id = 2; +} + +message WorkflowRunGrpcResponse { + // Whether the server accepted the workflow run request. + bool accepted = 1; + // The assigned workflow run ID. + string workflow_run_id = 2; + // Human-readable status message. + string message = 3; +} diff --git a/src/Werkr.Common/Rendering/AnsiHtmlConverter.cs b/src/Werkr.Common/Rendering/AnsiHtmlConverter.cs new file mode 100644 index 0000000..dffb8bc --- /dev/null +++ b/src/Werkr.Common/Rendering/AnsiHtmlConverter.cs @@ -0,0 +1,231 @@ +using System.Net; +using System.Text; +using System.Text.RegularExpressions; + +namespace Werkr.Common.Rendering; + +/// +/// Converts ANSI SGR escape sequences to safe HTML <span> elements. +/// Handles the standard 16-color palette (foreground and background), bold, and reset. +/// Unsupported sequences are stripped. All text content is HTML-encoded to prevent XSS. +/// +public static partial class AnsiHtmlConverter { + /// Regex matching ANSI CSI SGR sequences: ESC [ (params) m. + [GeneratedRegex( @"\x1b\[([0-9;]*)m" )] + private static partial Regex SgrPattern( ); + + /// Standard 4-bit foreground color codes (30–37, 90–97) to CSS color strings. + private static readonly Dictionary s_fgColors = new( ) { + [30] = "#000", // Black + [31] = "#c00", // Red + [32] = "#0a0", // Green + [33] = "#a50", // Yellow/DarkYellow + [34] = "#00a", // Blue/DarkBlue + [35] = "#a0a", // Magenta/DarkMagenta + [36] = "#0aa", // Cyan/DarkCyan + [37] = "#ccc", // White/Gray + [90] = "#666", // DarkGray (bright black) + [91] = "#f55", // Bright Red + [92] = "#5f5", // Bright Green + [93] = "#ff5", // Bright Yellow + [94] = "#55f", // Bright Blue + [95] = "#f5f", // Bright Magenta + [96] = "#5ff", // Bright Cyan + [97] = "#fff", // Bright White + }; + + /// Standard 4-bit background color codes (40–47, 100–107) to CSS color strings. + private static readonly Dictionary s_bgColors = new( ) { + [40] = "#000", + [41] = "#c00", + [42] = "#0a0", + [43] = "#a50", + [44] = "#00a", + [45] = "#a0a", + [46] = "#0aa", + [47] = "#ccc", + [100] = "#666", + [101] = "#f55", + [102] = "#5f5", + [103] = "#ff5", + [104] = "#55f", + [105] = "#f5f", + [106] = "#5ff", + [107] = "#fff", + }; + + /// + /// Converts a string containing ANSI SGR escape sequences to safe HTML. + /// Text content is HTML-encoded; color/bold sequences become inline style spans. + /// + /// Raw text potentially containing ANSI escape sequences. + /// HTML string safe for rendering via MarkupString. + public static string Convert( string input ) { + if (string.IsNullOrEmpty( input )) { + return string.Empty; + } + + // Fast path: no escape sequences at all + if (!input.Contains( '\x1b' )) { + return WebUtility.HtmlEncode( input ); + } + + StringBuilder sb = new( input.Length * 2 ); + string? currentFg = null; + string? currentBg = null; + bool currentBold = false; + bool spanOpen = false; + int lastIndex = 0; + + foreach (Match match in SgrPattern( ).Matches( input )) { + // Emit text before this escape sequence (HTML-encoded) + if (match.Index > lastIndex) { + string text = input[lastIndex..match.Index]; + if (!spanOpen && (currentFg is not null || currentBg is not null || currentBold)) { + _ = sb.Append( BuildSpanOpen( currentFg, currentBg, currentBold ) ); + spanOpen = true; + } + _ = sb.Append( WebUtility.HtmlEncode( text ) ); + } + + lastIndex = match.Index + match.Length; + + // Parse SGR parameters + string paramStr = match.Groups[1].Value; + int[] codes = string.IsNullOrEmpty( paramStr ) + ? [0] + : [.. paramStr.Split( ';' ).Select( s => int.TryParse( s, out int v ) ? v : 0 )]; + + foreach (int code in codes) { + if (code == 0) { + // Reset all + if (spanOpen) { + _ = sb.Append( "" ); + spanOpen = false; + } + currentFg = null; + currentBg = null; + currentBold = false; + } else if (code == 1) { + if (spanOpen) { _ = sb.Append( "" ); spanOpen = false; } + currentBold = true; + } else if (code == 22) { + if (spanOpen) { _ = sb.Append( "" ); spanOpen = false; } + currentBold = false; + } else if (s_fgColors.TryGetValue( code, out string? fg )) { + if (spanOpen) { _ = sb.Append( "" ); spanOpen = false; } + currentFg = fg; + } else if (s_bgColors.TryGetValue( code, out string? bg )) { + if (spanOpen) { _ = sb.Append( "" ); spanOpen = false; } + currentBg = bg; + } else if (code == 39) { + // Default foreground + if (spanOpen) { _ = sb.Append( "" ); spanOpen = false; } + currentFg = null; + } else if (code == 49) { + // Default background + if (spanOpen) { _ = sb.Append( "" ); spanOpen = false; } + currentBg = null; + } + // All other codes are silently ignored (stripped) + } + } + + // Emit remaining text + if (lastIndex < input.Length) { + string remaining = input[lastIndex..]; + if (!spanOpen && (currentFg is not null || currentBg is not null || currentBold)) { + _ = sb.Append( BuildSpanOpen( currentFg, currentBg, currentBold ) ); + spanOpen = true; + } + _ = sb.Append( WebUtility.HtmlEncode( remaining ) ); + } + + if (spanOpen) { + _ = sb.Append( "" ); + } + + return sb.ToString( ); + } + + /// + /// Strips all ANSI escape sequences from the input, returning plain text. + /// + /// Raw text potentially containing ANSI escape sequences. + /// Plain text with all ANSI sequences removed. + public static string Strip( string input ) { + return string.IsNullOrEmpty( input ) || !input.Contains( '\x1b' ) ? input : SgrPattern( ).Replace( input, string.Empty ); + } + + /// + /// Maps a value to its ANSI SGR foreground escape sequence. + /// + /// The console color. + /// The ANSI escape sequence string (e.g., \x1b[31m for Red). + public static string ConsoleColorToAnsi( ConsoleColor color ) => + color switch { + ConsoleColor.Black => "\x1b[30m", + ConsoleColor.DarkRed => "\x1b[31m", + ConsoleColor.DarkGreen => "\x1b[32m", + ConsoleColor.DarkYellow => "\x1b[33m", + ConsoleColor.DarkBlue => "\x1b[34m", + ConsoleColor.DarkMagenta => "\x1b[35m", + ConsoleColor.DarkCyan => "\x1b[36m", + ConsoleColor.Gray => "\x1b[37m", + ConsoleColor.DarkGray => "\x1b[90m", + ConsoleColor.Red => "\x1b[91m", + ConsoleColor.Green => "\x1b[92m", + ConsoleColor.Yellow => "\x1b[93m", + ConsoleColor.Blue => "\x1b[94m", + ConsoleColor.Magenta => "\x1b[95m", + ConsoleColor.Cyan => "\x1b[96m", + ConsoleColor.White => "\x1b[97m", + _ => string.Empty, + }; + + /// + /// Maps a value to its ANSI SGR background escape sequence. + /// + /// The console color. + /// The ANSI escape sequence string (e.g., \x1b[41m for Red bg). + public static string ConsoleColorToBgAnsi( ConsoleColor color ) => + color switch { + ConsoleColor.Black => "\x1b[40m", + ConsoleColor.DarkRed => "\x1b[41m", + ConsoleColor.DarkGreen => "\x1b[42m", + ConsoleColor.DarkYellow => "\x1b[43m", + ConsoleColor.DarkBlue => "\x1b[44m", + ConsoleColor.DarkMagenta => "\x1b[45m", + ConsoleColor.DarkCyan => "\x1b[46m", + ConsoleColor.Gray => "\x1b[47m", + ConsoleColor.DarkGray => "\x1b[100m", + ConsoleColor.Red => "\x1b[101m", + ConsoleColor.Green => "\x1b[102m", + ConsoleColor.Yellow => "\x1b[103m", + ConsoleColor.Blue => "\x1b[104m", + ConsoleColor.Magenta => "\x1b[105m", + ConsoleColor.Cyan => "\x1b[106m", + ConsoleColor.White => "\x1b[107m", + _ => string.Empty, + }; + + /// The ANSI reset sequence. + public const string AnsiReset = "\x1b[0m"; + + private static string BuildSpanOpen( string? fg, string? bg, bool bold ) { + StringBuilder style = new( ); + if (fg is not null) { + _ = style.Append( $"color:{fg};" ); + } + + if (bg is not null) { + _ = style.Append( $"background:{bg};" ); + } + + if (bold) { + _ = style.Append( "font-weight:bold;" ); + } + + return $""; + } +} diff --git a/src/Werkr.Common/Werkr.Common.csproj b/src/Werkr.Common/Werkr.Common.csproj new file mode 100644 index 0000000..e805626 --- /dev/null +++ b/src/Werkr.Common/Werkr.Common.csproj @@ -0,0 +1,25 @@ + + + + Werkr.Common + + + + + + + + + + + + + + + + + true + + + + diff --git a/src/Werkr.Common/packages.lock.json b/src/Werkr.Common/packages.lock.json new file mode 100644 index 0000000..00af67a --- /dev/null +++ b/src/Werkr.Common/packages.lock.json @@ -0,0 +1,195 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Google.Protobuf": { + "type": "Direct", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.78.0, )", + "resolved": "2.78.0", + "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" + }, + "Microsoft.AspNetCore.Authorization": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Direct", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + } + } + } +} \ No newline at end of file diff --git a/src/Werkr.Core/Communication/AgentConnectionManager.cs b/src/Werkr.Core/Communication/AgentConnectionManager.cs new file mode 100644 index 0000000..88cc35d --- /dev/null +++ b/src/Werkr.Core/Communication/AgentConnectionManager.cs @@ -0,0 +1,144 @@ +using System.Collections.Concurrent; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Werkr.Common.Models; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Core.Communication; + +/// +/// Manages gRPC channels to registered Agents. Caches channels in a +/// keyed by connection ID. +/// Registered as a Singleton; queries via +/// to resolve connection details. +/// +/// Initializes a new instance of the class. +/// Factory for creating DI scopes to resolve scoped services. +/// Logger instance. +public sealed class AgentConnectionManager( + IServiceScopeFactory scopeFactory, + ILogger logger +) : IDisposable { + private readonly ConcurrentDictionary _channels = new( ); + + /// + /// Gets or creates a gRPC channel to the specified agent. + /// Returns the channel along with the connection details needed for call credentials. + /// + /// The for the Server-side record. + /// Cancellation token. + /// A tuple of the and the . + public async Task<(GrpcChannel Channel, RegisteredConnection Connection)> GetChannelAsync( + Guid agentConnectionId, + CancellationToken cancellationToken = default ) { + + // Try cache first + if (_channels.TryGetValue( agentConnectionId, out GrpcChannel? cached ) && + cached.State != ConnectivityState.Shutdown) { + // Still need the connection record for credentials + RegisteredConnection cachedConn = await ResolveConnectionAsync( agentConnectionId, cancellationToken ); + return (cached, cachedConn); + } + + RegisteredConnection connection = await ResolveConnectionAsync( agentConnectionId, cancellationToken ); + + GrpcChannel channel = GrpcChannel.ForAddress( connection.RemoteUrl, new GrpcChannelOptions { + HttpHandler = CreateHttpHandler( ) + } ); + + _ = _channels.AddOrUpdate( agentConnectionId, channel, ( _, old ) => { + old.Dispose( ); + return channel; + } ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Created gRPC channel to Agent {AgentId} at {Url}.", + agentConnectionId.ToString( ), connection.RemoteUrl ); + } + + return (channel, connection); + } + + /// + /// Creates gRPC with bearer token, connection ID, call ID, and deadline. + /// + /// The resolved . + /// Optional call ID for tracing. Generated if null. + /// Cancellation token. + /// Command timeout. Defaults to 30 minutes if null. + /// Configured . + public static CallOptions CreateCallOptions( + RegisteredConnection connection, + Guid? callId = null, + CancellationToken cancellationToken = default, + TimeSpan? timeout = null ) { + + Metadata metadata = new( ) { + { "authorization", $"Bearer {connection.OutboundApiKey}" }, + { "x-werkr-connection-id", connection.Id.ToString( ) }, + { "x-werkr-call-id", (callId ?? Guid.NewGuid( )).ToString( ) } + }; + + TimeSpan effectiveTimeout = timeout ?? TimeSpan.FromMinutes( 30 ); + DateTime deadline = DateTime.UtcNow + effectiveTimeout; + + return new CallOptions( + headers: metadata, + deadline: deadline, + cancellationToken: cancellationToken ); + } + + /// + /// Removes and disposes the cached channel for a specific agent. + /// Call when a connection is revoked or an agent becomes unreachable. + /// + /// The connection ID to remove. + public void RemoveChannel( Guid agentConnectionId ) { + if (_channels.TryRemove( agentConnectionId, out GrpcChannel? channel )) { + channel.Dispose( ); + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Removed gRPC channel for Agent {AgentId}.", agentConnectionId.ToString( ) ); + } + } + } + + /// + public void Dispose( ) { + foreach (KeyValuePair kvp in _channels) { + kvp.Value.Dispose( ); + } + _channels.Clear( ); + } + + /// + /// Creates an for gRPC channels. + /// All connections use TLS — ALPN negotiates HTTP/2 automatically. + /// + private static SocketsHttpHandler CreateHttpHandler( ) => new( ) { + EnableMultipleHttp2Connections = true, + KeepAlivePingDelay = TimeSpan.FromSeconds( 30 ), + KeepAlivePingTimeout = TimeSpan.FromSeconds( 10 ), + }; + + private async Task ResolveConnectionAsync( + Guid agentConnectionId, + CancellationToken cancellationToken ) { + + using IServiceScope scope = scopeFactory.CreateScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + RegisteredConnection? connection = await dbContext.RegisteredConnections + .AsNoTracking( ) + .FirstOrDefaultAsync( c => c.Id == agentConnectionId && c.IsServer, cancellationToken ); + + return connection is null || connection.Status == ConnectionStatus.Revoked + ? throw new InvalidOperationException( + $"Agent connection '{agentConnectionId}' not found or revoked." ) + : connection; + } +} diff --git a/src/Werkr.Core/Communication/CommandDispatchFailure.cs b/src/Werkr.Core/Communication/CommandDispatchFailure.cs new file mode 100644 index 0000000..0f4187a --- /dev/null +++ b/src/Werkr.Core/Communication/CommandDispatchFailure.cs @@ -0,0 +1,24 @@ +namespace Werkr.Core.Communication; + +/// +/// Categorizes the reason a command dispatch failed. +/// +public enum CommandDispatchFailure { + /// The agent connection ID was not found in the database. + AgentNotFound, + + /// The agent connection exists but has been revoked. + AgentRevoked, + + /// The agent is unreachable (gRPC transport failure, DNS, timeout). + AgentUnreachable, + + /// TLS handshake or certificate validation failed. + TlsError, + + /// Payload encryption or decryption failed. + EncryptionError, + + /// An unknown or uncategorized error occurred. + Unknown +} diff --git a/src/Werkr.Core/Communication/CommandDispatcher.cs b/src/Werkr.Core/Communication/CommandDispatcher.cs new file mode 100644 index 0000000..c14254f --- /dev/null +++ b/src/Werkr.Core/Communication/CommandDispatcher.cs @@ -0,0 +1,341 @@ +using System.Runtime.CompilerServices; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Text.Json; + +using Grpc.Core; + +using Microsoft.Extensions.Logging; + +using Werkr.Agent.Protos; +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Common.Protos; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Core.Communication; + +/// +/// Orchestrates sending commands to Agents via gRPC and yielding decrypted output. +/// Single entry point for all command execution. All payloads are wrapped in +/// using the connection's SharedKey. +/// +/// Initializes a new instance of the class. +/// Agent connection manager for channel resolution. +/// Logger instance. +public sealed class CommandDispatcher( + AgentConnectionManager connectionManager, + ILogger logger +) : ICommandDispatcher { + + /// + /// Executes a command on the specified agent and yields decrypted output. + /// + /// The Server-side . + /// The operator type to use (PowerShell, SystemShell). + /// The plaintext command to execute. + /// Cancellation token for timeout/abort. + /// An async enumerable of decrypted records. + public async IAsyncEnumerable ExecuteCommandAsync( + Guid agentConnectionId, + OperatorType operatorType, + string command, + [EnumeratorCancellation] CancellationToken cancellationToken = default ) { + + (Grpc.Net.Client.GrpcChannel channel, RegisteredConnection connection) = + await ResolveChannelAsync( agentConnectionId, cancellationToken ); + + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + EncryptedEnvelope envelope = EncryptRequest( + new ShellRequest { Command = command }, connection.SharedKey, keyId, agentConnectionId ); + + Guid callId = Guid.NewGuid( ); + CallOptions callOptions = AgentConnectionManager.CreateCallOptions( + connection, callId, cancellationToken ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Dispatching {OperatorType} command to Agent {AgentId}, CallId {CallId}.", + operatorType.ToString( ), agentConnectionId.ToString( ), callId.ToString( ) ); + } + + AsyncServerStreamingCall call; + + switch (operatorType) { + case OperatorType.PowerShell: + Pwsh.PwshClient pwshClient = new( channel ); + call = pwshClient.RunCommand( envelope, callOptions ); + break; + + case OperatorType.SystemShell: + SystemShell.SystemShellClient shellClient = new( channel ); + call = shellClient.RunCommand( envelope, callOptions ); + break; + + default: + yield return OperatorOutput.Create( + "Error", $"Unsupported operator type: {operatorType}" ); + yield break; + } + + using (call) { + IAsyncEnumerable stream = GrpcOutputReader.ReadAsync( + call.ResponseStream, connection.SharedKey, cancellationToken ); + + IAsyncEnumerator enumerator = stream.GetAsyncEnumerator( cancellationToken ); + try { + while (true) { + bool moved; + try { + moved = await enumerator.MoveNextAsync( ); + } catch (Exception ex) { + throw TranslateException( ex, agentConnectionId ); + } + if (!moved) { + break; + } + + yield return enumerator.Current; + } + } finally { + await enumerator.DisposeAsync( ); + } + } + } + + /// + /// Executes a script on the specified agent and yields decrypted output. + /// + /// The Server-side . + /// The operator type to use (PowerShell, SystemShell). + /// The script path to execute. + /// Optional script arguments. + /// Cancellation token for timeout/abort. + /// An async enumerable of decrypted records. + public async IAsyncEnumerable ExecuteScriptAsync( + Guid agentConnectionId, + OperatorType operatorType, + string scriptPath, + IEnumerable? args, + [EnumeratorCancellation] CancellationToken cancellationToken = default ) { + + (Grpc.Net.Client.GrpcChannel channel, RegisteredConnection connection) = + await ResolveChannelAsync( agentConnectionId, cancellationToken ); + + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + Guid callId = Guid.NewGuid( ); + CallOptions callOptions = AgentConnectionManager.CreateCallOptions( + connection, callId, cancellationToken ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Dispatching {OperatorType} script to Agent {AgentId}, CallId {CallId}.", + operatorType.ToString( ), agentConnectionId.ToString( ), callId.ToString( ) ); + } + + AsyncServerStreamingCall call; + + if (args is not null) { + ScriptRequest innerRequest = new( ) { Script = scriptPath }; + innerRequest.Args.AddRange( args ); + + EncryptedEnvelope envelope = EncryptRequest( + innerRequest, connection.SharedKey, keyId, agentConnectionId ); + + switch (operatorType) { + case OperatorType.PowerShell: + call = new Pwsh.PwshClient( channel ) + .RunScriptWithArgs( envelope, callOptions ); + break; + case OperatorType.SystemShell: + call = new SystemShell.SystemShellClient( channel ) + .RunScriptWithArgs( envelope, callOptions ); + break; + default: + yield return OperatorOutput.Create( + "Error", $"Unsupported operator type: {operatorType}" ); + yield break; + } + } else { + EncryptedEnvelope envelope = EncryptRequest( + new ShellRequest { Command = scriptPath }, connection.SharedKey, keyId, agentConnectionId ); + + switch (operatorType) { + case OperatorType.PowerShell: + call = new Pwsh.PwshClient( channel ) + .RunScript( envelope, callOptions ); + break; + case OperatorType.SystemShell: + call = new SystemShell.SystemShellClient( channel ) + .RunScript( envelope, callOptions ); + break; + default: + yield return OperatorOutput.Create( + "Error", $"Unsupported operator type: {operatorType}" ); + yield break; + } + } + + using (call) { + IAsyncEnumerable stream = GrpcOutputReader.ReadAsync( + call.ResponseStream, connection.SharedKey, cancellationToken ); + + IAsyncEnumerator enumerator = stream.GetAsyncEnumerator( cancellationToken ); + try { + while (true) { + bool moved; + try { + moved = await enumerator.MoveNextAsync( ); + } catch (Exception ex) { + throw TranslateException( ex, agentConnectionId ); + } + if (!moved) { + break; + } + + yield return enumerator.Current; + } + } finally { + await enumerator.DisposeAsync( ); + } + } + } + + /// + /// Executes a built-in action on the specified agent and yields decrypted output. + /// + /// The Server-side . + /// The action descriptor containing action name and JSON parameters. + /// Cancellation token for timeout/abort. + /// An async enumerable of decrypted records. + public async IAsyncEnumerable ExecuteActionAsync( + Guid agentConnectionId, + ActionDescriptor descriptor, + [EnumeratorCancellation] CancellationToken cancellationToken = default ) { + + (Grpc.Net.Client.GrpcChannel channel, RegisteredConnection connection) = + await ResolveChannelAsync( agentConnectionId, cancellationToken ); + + string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); + + ActionRequest actionRequest = new( ) { + ActionName = descriptor.Action, + ParametersJson = JsonSerializer.Serialize( descriptor.Parameters ), + }; + + EncryptedEnvelope envelope = EncryptRequest( + actionRequest, connection.SharedKey, keyId, agentConnectionId ); + + Guid callId = Guid.NewGuid( ); + CallOptions callOptions = AgentConnectionManager.CreateCallOptions( + connection, callId, cancellationToken ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Dispatching Action '{ActionName}' to Agent {AgentId}, CallId {CallId}.", + descriptor.Action, agentConnectionId.ToString( ), callId.ToString( ) ); + } + + Werkr.Agent.Protos.Action.ActionClient actionClient = new( channel ); + using AsyncServerStreamingCall call = actionClient.RunAction( envelope, callOptions ); + + IAsyncEnumerable stream = GrpcOutputReader.ReadAsync( + call.ResponseStream, connection.SharedKey, cancellationToken ); + + IAsyncEnumerator enumerator = stream.GetAsyncEnumerator( cancellationToken ); + try { + while (true) { + bool moved; + try { + moved = await enumerator.MoveNextAsync( ); + } catch (Exception ex) { + throw TranslateException( ex, agentConnectionId ); + } + if (!moved) { + break; + } + + yield return enumerator.Current; + } + } finally { + await enumerator.DisposeAsync( ); + } + } + + /// + /// Resolves the gRPC channel for the agent, translating raw exceptions + /// into . + /// + private async Task<(Grpc.Net.Client.GrpcChannel Channel, RegisteredConnection Connection)> + ResolveChannelAsync( Guid agentConnectionId, CancellationToken cancellationToken ) { + try { + return await connectionManager.GetChannelAsync( agentConnectionId, cancellationToken ); + } catch (InvalidOperationException ex) when ( + ex.Message.Contains( "not found", StringComparison.OrdinalIgnoreCase )) { + throw new CommandDispatcherException( + CommandDispatchFailure.AgentNotFound, + $"Agent connection '{agentConnectionId}' was not found.", + agentConnectionId, ex ); + } catch (InvalidOperationException ex) when ( + ex.Message.Contains( "revoked", StringComparison.OrdinalIgnoreCase )) { + throw new CommandDispatcherException( + CommandDispatchFailure.AgentRevoked, + $"Agent connection '{agentConnectionId}' has been revoked.", + agentConnectionId, ex ); + } catch (InvalidOperationException ex) { + throw new CommandDispatcherException( + CommandDispatchFailure.AgentNotFound, + ex.Message, + agentConnectionId, ex ); + } + } + + /// + /// Encrypts a protobuf request into an , + /// wrapping cryptographic failures into a typed exception. + /// + private static EncryptedEnvelope EncryptRequest( + T message, byte[] sharedKey, string keyId, Guid agentConnectionId ) + where T : Google.Protobuf.IMessage { + try { + return PayloadEncryptor.EncryptToEnvelope( message, sharedKey, keyId ); + } catch (CryptographicException ex) { + throw new CommandDispatcherException( + CommandDispatchFailure.EncryptionError, + "Failed to encrypt the command payload.", + agentConnectionId, ex ); + } + } + + /// + /// Translates raw exceptions into . + /// + private static CommandDispatcherException TranslateException( Exception ex, Guid agentConnectionId ) => + ex switch { + CommandDispatcherException cde => cde, + RpcException rpc when rpc.StatusCode == StatusCode.Unavailable => + new CommandDispatcherException( + CommandDispatchFailure.AgentUnreachable, + $"Agent '{agentConnectionId}' is unreachable.", + agentConnectionId, rpc ), + RpcException rpc => + new CommandDispatcherException( + CommandDispatchFailure.AgentUnreachable, + $"gRPC error communicating with agent '{agentConnectionId}': {rpc.Status.Detail}", + agentConnectionId, rpc ), + AuthenticationException auth => + new CommandDispatcherException( + CommandDispatchFailure.TlsError, + "TLS authentication failed while connecting to the agent.", + agentConnectionId, auth ), + CryptographicException crypto => + new CommandDispatcherException( + CommandDispatchFailure.EncryptionError, + "Payload decryption failed.", + agentConnectionId, crypto ), + _ => new CommandDispatcherException( + CommandDispatchFailure.Unknown, + ex.Message, + agentConnectionId, ex ) + }; +} diff --git a/src/Werkr.Core/Communication/CommandDispatcherException.cs b/src/Werkr.Core/Communication/CommandDispatcherException.cs new file mode 100644 index 0000000..3c90007 --- /dev/null +++ b/src/Werkr.Core/Communication/CommandDispatcherException.cs @@ -0,0 +1,48 @@ +namespace Werkr.Core.Communication; + +/// +/// Typed exception thrown by to provide +/// actionable error context to callers without leaking raw internal details. +/// +public sealed class CommandDispatcherException : Exception { + /// The categorized failure reason. + public CommandDispatchFailure Reason { get; } + + /// The agent connection ID the dispatch targeted, if known. + public Guid? AgentConnectionId { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The categorized failure reason. + /// The internal error message (for logging). + /// The agent connection ID, if known. + /// The original exception, if any. + public CommandDispatcherException( + CommandDispatchFailure reason, + string message, + Guid? agentConnectionId = null, + Exception? innerException = null ) + : base( message, innerException ) { + Reason = reason; + AgentConnectionId = agentConnectionId; + } + + /// + /// Returns a user-safe description of the failure that avoids exposing internal details. + /// + public string UserMessage => Reason switch { + CommandDispatchFailure.AgentNotFound => + "The specified agent was not found. It may have been removed.", + CommandDispatchFailure.AgentRevoked => + "The agent connection has been revoked and can no longer accept commands.", + CommandDispatchFailure.AgentUnreachable => + "The agent is unreachable. Verify that it is running and network connectivity is intact.", + CommandDispatchFailure.TlsError => + "A TLS/certificate error occurred communicating with the agent. Check certificate configuration.", + CommandDispatchFailure.EncryptionError => + "Failed to encrypt or decrypt the command payload. The shared key may be invalid.", + _ => + "An unexpected error occurred while dispatching the command." + }; +} diff --git a/src/Werkr.Core/Communication/GrpcOutputReader.cs b/src/Werkr.Core/Communication/GrpcOutputReader.cs new file mode 100644 index 0000000..8d4230f --- /dev/null +++ b/src/Werkr.Core/Communication/GrpcOutputReader.cs @@ -0,0 +1,38 @@ +using System.Runtime.CompilerServices; + +using Grpc.Core; + +using Werkr.Agent.Protos; +using Werkr.Common.Protos; + +namespace Werkr.Core.Communication; + +/// +/// Reads a gRPC server-streaming response of messages, +/// decrypts each envelope to a , and yields +/// records. +/// +public static class GrpcOutputReader { + /// + /// Reads encrypted messages from the response stream, + /// decrypts them to using the connection's SharedKey, + /// and yields . + /// + /// The gRPC response stream reader. + /// The AES-256 shared key for payload decryption. Must not be null. + /// Cancellation token. + /// An async enumerable of decrypted records. + /// Thrown when is null. + public static async IAsyncEnumerable ReadAsync( + IAsyncStreamReader responseStream, + byte[] sharedKey, + [EnumeratorCancellation] CancellationToken cancellationToken = default ) { + + ArgumentNullException.ThrowIfNull( sharedKey, nameof( sharedKey ) ); + + await foreach (EncryptedEnvelope envelope in responseStream.ReadAllAsync( cancellationToken )) { + GrpcLogMsg msg = PayloadEncryptor.DecryptFromEnvelope( envelope, sharedKey ); + yield return new OperatorOutput( msg.LogLevel, msg.Message, msg.Timestamp ); + } + } +} diff --git a/src/Werkr.Core/Communication/ICommandDispatcher.cs b/src/Werkr.Core/Communication/ICommandDispatcher.cs new file mode 100644 index 0000000..33d99b2 --- /dev/null +++ b/src/Werkr.Core/Communication/ICommandDispatcher.cs @@ -0,0 +1,51 @@ +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; + +namespace Werkr.Core.Communication; + +/// +/// Dispatches commands and scripts to registered Agents and streams output. +/// +public interface ICommandDispatcher { + /// + /// Executes a command on the specified agent and yields output. + /// + /// The Server-side agent connection id. + /// The operator type to execute with. + /// The command to execute. + /// Cancellation token for timeout or cancellation. + /// Streamed command output. + IAsyncEnumerable ExecuteCommandAsync( + Guid agentConnectionId, + OperatorType operatorType, + string command, + CancellationToken cancellationToken = default ); + + /// + /// Executes a script on the specified agent and yields output. + /// + /// The Server-side agent connection id. + /// The operator type to execute with. + /// The script path to execute. + /// Optional script arguments. + /// Cancellation token for timeout or cancellation. + /// Streamed script output. + IAsyncEnumerable ExecuteScriptAsync( + Guid agentConnectionId, + OperatorType operatorType, + string scriptPath, + IEnumerable? args, + CancellationToken cancellationToken = default ); + + /// + /// Executes a built-in action on the specified agent and yields output. + /// + /// The Server-side agent connection id. + /// The action descriptor containing action name and parameters. + /// Cancellation token for timeout or cancellation. + /// Streamed action output. + IAsyncEnumerable ExecuteActionAsync( + Guid agentConnectionId, + ActionDescriptor descriptor, + CancellationToken cancellationToken = default ); +} diff --git a/src/Werkr.Core/Communication/KeyRotationService.cs b/src/Werkr.Core/Communication/KeyRotationService.cs new file mode 100644 index 0000000..fb296b3 --- /dev/null +++ b/src/Werkr.Core/Communication/KeyRotationService.cs @@ -0,0 +1,181 @@ +using System.Security.Cryptography; + +using Google.Protobuf; + +using Grpc.Core; +using Grpc.Net.Client; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using Werkr.Common.Models; +using Werkr.Common.Protos; +using Werkr.Core.Cryptography; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Core.Communication; + +/// +/// Background service that periodically rotates the AES-256-GCM SharedKey +/// for every connected agent. Generates a new key, RSA-encrypts it with the +/// Agent's public key, and sends it via the RotateSharedKey gRPC RPC. +/// On success, the previous key is retained for a grace period to handle in-flight messages. +/// +/// Service scope factory for per-sweep database contexts. +/// Singleton gRPC channel cache. +/// Logger for diagnostics. +/// How often to rotate keys (default: 24 hours). +public class KeyRotationService( + IServiceScopeFactory scopeFactory, + AgentConnectionManager connectionManager, + ILogger logger, + TimeSpan? rotationInterval = null +) : BackgroundService { + private readonly TimeSpan _rotationInterval = rotationInterval ?? TimeSpan.FromHours( 24 ); + + /// + protected override async Task ExecuteAsync( CancellationToken stoppingToken ) { + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "KeyRotationService started. Rotation interval: {Interval}.", + _rotationInterval ); + } + + while (!stoppingToken.IsCancellationRequested) { + // Wait first, then rotate — gives the system time to stabilize after startup + await Task.Delay( _rotationInterval, stoppingToken ); + + try { + await RotateAllAgentsAsync( stoppingToken ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + logger.LogError( ex, "Error in KeyRotationService sweep." ); + } + } + } + + private async Task RotateAllAgentsAsync( CancellationToken ct ) { + using IServiceScope scope = scopeFactory.CreateScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + List agents = await dbContext.RegisteredConnections + .Where( c => c.IsServer && c.Status == ConnectionStatus.Connected ) + .ToListAsync( ct ); + + if (agents.Count == 0) { + return; + } + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Starting key rotation for {Count} agents.", agents.Count ); + } + + foreach (RegisteredConnection agent in agents) { + ct.ThrowIfCancellationRequested( ); + _ = await RotateAgentKeyAsync( agent, dbContext, ct ); + } + } + + /// + /// Rotates the shared key for a single agent. + /// Called by both the background sweep and the manual rotation endpoint. + /// + /// The connection ID of the agent to rotate. + /// Cancellation token. + /// True if the rotation succeeded; false otherwise. + public async Task RotateSingleAgentAsync( Guid agentId, CancellationToken ct ) { + using IServiceScope scope = scopeFactory.CreateScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + RegisteredConnection? agent = await dbContext.RegisteredConnections + .FirstOrDefaultAsync( c => c.Id == agentId && c.IsServer && c.Status == ConnectionStatus.Connected, ct ); + + if (agent is null) { + logger.LogWarning( "Agent {AgentId} not found or not connected for key rotation.", agentId ); + return false; + } + + return await RotateAgentKeyAsync( agent, dbContext, ct ); + } + + internal async Task RotateAgentKeyAsync( RegisteredConnection agent, WerkrDbContext dbContext, CancellationToken ct ) { + try { + // 1. Generate new 256-bit AES key and key ID + byte[] newKey = EncryptionProvider.GenerateRandomBytes( EncryptionProvider.AesGcmKeySize ); + string newKeyId = Guid.NewGuid( ).ToString( "N" ); + + // 2. RSA-encrypt the new key with the Agent's public key + using RSA rsa = RSA.Create( ); + rsa.ImportParameters( agent.RemotePublicKey ); + byte[] rsaEncryptedNewKey = rsa.Encrypt( newKey, RSAEncryptionPadding.OaepSHA256 ); + + // 3. Send RotateSharedKey RPC via the existing encrypted channel + (GrpcChannel channel, RegisteredConnection resolved) = + await connectionManager.GetChannelAsync( agent.Id, ct ); + + string currentKeyId = resolved.ActiveKeyId ?? resolved.Id.ToString( ); + + RotateSharedKeyRequest rotationRequest = new( ) { + RsaEncryptedNewKey = ByteString.CopyFrom( rsaEncryptedNewKey ), + NewKeyId = newKeyId, + }; + + EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( + rotationRequest, resolved.SharedKey, currentKeyId ); + + CallOptions callOptions = AgentConnectionManager.CreateCallOptions( + resolved, + timeout: TimeSpan.FromSeconds( 30 ), + cancellationToken: ct ); + + ConnectionManagement.ConnectionManagementClient client = new( channel ); + EncryptedEnvelope responseEnvelope = await client.RotateSharedKeyAsync( envelope, callOptions ); + + // 4. The agent responds with the NEW key, so decrypt with the new key + RotateSharedKeyResponse response = PayloadEncryptor.DecryptFromEnvelope( + responseEnvelope, newKey ); + + if (!response.Success) { + logger.LogWarning( + "Agent {AgentId} ({Name}) rejected key rotation. ActiveKeyId={ActiveKeyId}.", + agent.Id, agent.ConnectionName, response.ActiveKeyId ); + return false; + } + + // 5. Persist: move current key to previous, install new key on API side + agent.PreviousSharedKey = agent.SharedKey; + agent.PreviousKeyId = agent.ActiveKeyId; + agent.SharedKey = newKey; + agent.ActiveKeyId = newKeyId; + _ = await dbContext.SaveChangesAsync( ct ); + + // 6. Reset the cached channel so it picks up the refreshed connection + connectionManager.RemoveChannel( agent.Id ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Key rotation succeeded for Agent {AgentId} ({Name}). NewKeyId={NewKeyId}.", + agent.Id, agent.ConnectionName, newKeyId ); + } + + return true; + } catch (RpcException ex) { + logger.LogWarning( ex, + "Key rotation RPC failed for Agent {AgentId} ({Name}). Status={Status}.", + agent.Id, agent.ConnectionName, ex.StatusCode ); + return false; + } catch (CryptographicException ex) { + logger.LogError( ex, + "Key rotation cryptographic failure for Agent {AgentId} ({Name}).", + agent.Id, agent.ConnectionName ); + return false; + } catch (Exception ex) when (ex is not OperationCanceledException) { + logger.LogError( ex, + "Unexpected error during key rotation for Agent {AgentId} ({Name}).", + agent.Id, agent.ConnectionName ); + return false; + } + } +} diff --git a/src/Werkr.Core/Communication/OperatorOutput.cs b/src/Werkr.Core/Communication/OperatorOutput.cs new file mode 100644 index 0000000..5175c10 --- /dev/null +++ b/src/Werkr.Core/Communication/OperatorOutput.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; + +namespace Werkr.Core.Communication; + +/// +/// Transport-agnostic output record from an operator execution. +/// Maps to GrpcLogMsg for gRPC transport and to SignalR messages for browser delivery. +/// +/// The severity level of the output line (e.g., Trace, Debug, Information, Warning, Error). +/// The output content. +/// ISO 8601 UTC timestamp of when the output was produced. +public sealed record OperatorOutput( + string LogLevel, + string Message, + string Timestamp ) { + + /// Creates an with the current UTC timestamp. + /// The severity level. + /// The output content. + /// A new instance. + public static OperatorOutput Create( string logLevel, string message ) => + new( logLevel, message, DateTime.UtcNow.ToString( "o" ) ); + + /// Creates an with the current UTC timestamp. + /// The severity level. + /// The output content. + /// A new instance. + public static OperatorOutput Create( LogLevel logLevel, string message ) => + Create( logLevel.ToString( ), message ); +} diff --git a/src/Werkr.Core/Communication/PayloadEncryptor.cs b/src/Werkr.Core/Communication/PayloadEncryptor.cs new file mode 100644 index 0000000..0b6bc93 --- /dev/null +++ b/src/Werkr.Core/Communication/PayloadEncryptor.cs @@ -0,0 +1,98 @@ +using Google.Protobuf; + +using Werkr.Common.Protos; +using Werkr.Core.Cryptography; + +namespace Werkr.Core.Communication; + +/// +/// Encrypts and decrypts gRPC payloads using AES-256-GCM with the connection's +/// pre-shared SharedKey. All gRPC messages (except Registration.proto) +/// are wrapped in an . +/// +public static class PayloadEncryptor { + /// + /// Encrypts a protobuf message into an . + /// + /// The protobuf message type. + /// The plaintext protobuf message to encrypt. + /// The 32-byte AES-256 symmetric key. + /// Identifier for the key used (supports key rotation). + /// An containing the encrypted payload. + /// Thrown when is null. + public static EncryptedEnvelope EncryptToEnvelope( T message, byte[] sharedKey, string keyId ) + where T : IMessage { + ArgumentNullException.ThrowIfNull( sharedKey, nameof( sharedKey ) ); + + byte[] plaintext = message.ToByteArray( ); + byte[] ciphertext = EncryptionProvider.AesGcmEncrypt( plaintext, sharedKey, out byte[] nonce, out byte[] tag ); + + return new EncryptedEnvelope { + Ciphertext = ByteString.CopyFrom( ciphertext ), + Iv = ByteString.CopyFrom( nonce ), + AuthTag = ByteString.CopyFrom( tag ), + KeyId = keyId, + }; + } + + /// + /// Decrypts an back to a protobuf message. + /// + /// The protobuf message type. + /// The encrypted envelope to decrypt. + /// The 32-byte AES-256 symmetric key. + /// The decrypted protobuf message. + /// Thrown when is null. + /// Thrown when decryption fails (wrong key or tampered data). + public static T DecryptFromEnvelope( EncryptedEnvelope envelope, byte[] sharedKey ) + where T : IMessage, new() { + ArgumentNullException.ThrowIfNull( sharedKey, nameof( sharedKey ) ); + + byte[] plaintext = EncryptionProvider.AesGcmDecrypt( + envelope.Ciphertext.ToByteArray( ), + sharedKey, + envelope.Iv.ToByteArray( ), + envelope.AuthTag.ToByteArray( ) ); + + MessageParser parser = new( ( ) => new T( ) ); + return parser.ParseFrom( plaintext ); + } + + /// + /// Decrypts an with key rotation support. + /// Tries the current key first. If the envelope's KeyId matches + /// and a previous key is available, falls back to that. + /// + /// The protobuf message type. + /// The encrypted envelope to decrypt. + /// The current 32-byte AES-256 symmetric key. + /// Key ID for the current key. + /// The previous key (may be null if no rotation in progress). + /// Key ID for the previous key (may be null). + /// The decrypted protobuf message. + /// Thrown when is null. + /// Thrown when decryption fails with all available keys. + public static T DecryptFromEnvelope( + EncryptedEnvelope envelope, + byte[] currentKey, + string currentKeyId, + byte[]? previousKey, + string? previousKeyId ) + where T : IMessage, new() { + ArgumentNullException.ThrowIfNull( currentKey, nameof( currentKey ) ); + + // If the key ID matches the current key, or no key ID is set, use current key + if (string.IsNullOrEmpty( envelope.KeyId ) || envelope.KeyId == currentKeyId) { + return DecryptFromEnvelope( envelope, currentKey ); + } + + // If the key ID matches the previous key and a previous key exists, use it + if (previousKey is not null && previousKeyId is not null && envelope.KeyId == previousKeyId) { + return DecryptFromEnvelope( envelope, previousKey ); + } + + // Key ID doesn't match any known key — try current key as a last resort + // (handles the case where key IDs haven't been synchronized yet) + return DecryptFromEnvelope( envelope, currentKey ); + } +} diff --git a/src/Werkr.Core/Cryptography/EncryptionProvider.cs b/src/Werkr.Core/Cryptography/EncryptionProvider.cs new file mode 100644 index 0000000..2c5ccaf --- /dev/null +++ b/src/Werkr.Core/Cryptography/EncryptionProvider.cs @@ -0,0 +1,355 @@ +using System.Security.Cryptography; +using System.Text.Json; + +namespace Werkr.Core.Cryptography; + +/// +/// Provides RSA-4096, AES-256-GCM, and hybrid cryptographic operations. +/// SHA-512 is the default for all hashing and RSA OAEP padding. +/// +public static class EncryptionProvider { + /// AES-GCM key size in bytes (256 bits). + public const int AesGcmKeySize = 32; + + /// AES-GCM nonce size in bytes (96 bits). + public const int AesGcmNonceSize = 12; + + /// AES-GCM authentication tag size in bytes (128 bits). + public const int AesGcmTagSize = 16; + + /// RSA-4096 encrypted output size in bytes. + public const int RsaEncryptedBlockSize = 512; + + // --- RSA Operations --- + + /// Generates a new RSA key pair of the specified size. + /// Key size in bits (minimum 2048, must be divisible by 8). Default is 4096. + /// An containing public and private parameters. + /// Thrown when key size is less than 2048 or not divisible by 8. + public static KeyInfo.RSAKeyPair GenerateRSAKeyPair( int keySize = 4096 ) { + if (keySize < 2048) { + throw new ArgumentOutOfRangeException( nameof( keySize ), keySize, "RSA key size must be at least 2048 bits." ); + } + + if (keySize % 8 != 0) { + throw new ArgumentOutOfRangeException( nameof( keySize ), keySize, "RSA key size must be divisible by 8." ); + } + + using RSA rsa = RSA.Create( keySize ); + RSAParameters publicKey = rsa.ExportParameters( includePrivateParameters: false ); + RSAParameters privateKey = rsa.ExportParameters( includePrivateParameters: true ); + return new KeyInfo.RSAKeyPair( publicKey, privateKey, keySize ); + } + + /// Encrypts data using RSA OAEP with SHA-512 padding. + /// The plaintext data to encrypt. Must be within RSA OAEP payload limit. + /// The recipient's RSA public key. + /// The RSA-encrypted ciphertext. + /// Thrown when encryption fails. + public static byte[] RSAEncrypt( byte[] data, RSAParameters publicKey ) { + try { + using RSA rsa = RSA.Create( ); + rsa.ImportParameters( publicKey ); + return rsa.Encrypt( data, RSAEncryptionPadding.OaepSHA512 ); + } catch (CryptographicException ex) { + throw new WerkrCryptoException( "RSA encryption failed — data may exceed OAEP payload limit or key is invalid.", ex ); + } + } + + /// Decrypts data using RSA OAEP with SHA-512 padding. + /// The ciphertext to decrypt. + /// The recipient's RSA private key. + /// The decrypted plaintext. + /// Thrown when decryption fails (wrong key or corrupted data). + public static byte[] RSADecrypt( byte[] data, RSAParameters privateKey ) { + try { + using RSA rsa = RSA.Create( ); + rsa.ImportParameters( privateKey ); + return rsa.Decrypt( data, RSAEncryptionPadding.OaepSHA512 ); + } catch (CryptographicException ex) { + throw new WerkrCryptoException( "RSA decryption failed — wrong key or corrupted data.", ex ); + } + } + + /// Signs data with an RSA private key using SHA-512. + /// The data to sign. + /// The signer's RSA private key. + /// The RSA signature bytes. + /// Thrown when signing fails. + public static byte[] Sign( byte[] data, RSAParameters privateKey ) { + try { + using RSA rsa = RSA.Create( ); + rsa.ImportParameters( privateKey ); + return rsa.SignData( data, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1 ); + } catch (CryptographicException ex) { + throw new WerkrCryptoException( "RSA signing failed.", ex ); + } + } + + /// Verifies an RSA signature using SHA-512. + /// The original data that was signed. + /// The signature to verify. + /// The signer's RSA public key. + /// true if the signature is valid; otherwise false. + public static bool Verify( byte[] data, byte[] signature, RSAParameters publicKey ) { + try { + using RSA rsa = RSA.Create( ); + rsa.ImportParameters( publicKey ); + return rsa.VerifyData( data, signature, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1 ); + } catch (CryptographicException) { + return false; + } + } + + // --- AES-256-GCM Operations --- + + /// Encrypts data using AES-256-GCM with a random nonce. + /// The data to encrypt. + /// The 32-byte AES-256 key. + /// Output: the 12-byte random nonce used. + /// Output: the 16-byte authentication tag. + /// The ciphertext bytes. + /// Thrown when encryption fails. + public static byte[] AesGcmEncrypt( byte[] plaintext, byte[] key, out byte[] nonce, out byte[] tag ) { + if (key.Length != AesGcmKeySize) { + throw new ArgumentException( $"AES-GCM key must be {AesGcmKeySize} bytes.", nameof( key ) ); + } + + try { + nonce = GenerateRandomBytes( AesGcmNonceSize ); + tag = new byte[AesGcmTagSize]; + byte[] ciphertext = new byte[plaintext.Length]; + + using AesGcm aes = new( key, AesGcmTagSize ); + aes.Encrypt( nonce, plaintext, ciphertext, tag ); + return ciphertext; + } catch (CryptographicException ex) { + throw new WerkrCryptoException( "AES-GCM encryption failed.", ex ); + } + } + + /// Decrypts data using AES-256-GCM. + /// The ciphertext to decrypt. + /// The 32-byte AES-256 key. + /// The 12-byte nonce used during encryption. + /// The 16-byte authentication tag. + /// The decrypted plaintext. + /// Thrown when decryption fails (wrong key, tampered data, or tag mismatch). + public static byte[] AesGcmDecrypt( byte[] ciphertext, byte[] key, byte[] nonce, byte[] tag ) { + if (key.Length != AesGcmKeySize) { + throw new ArgumentException( $"AES-GCM key must be {AesGcmKeySize} bytes.", nameof( key ) ); + } + + try { + byte[] plaintext = new byte[ciphertext.Length]; + using AesGcm aes = new( key, AesGcmTagSize ); + aes.Decrypt( nonce, ciphertext, tag, plaintext ); + return plaintext; + } catch (CryptographicException ex) { + throw new WerkrCryptoException( "AES-GCM authentication tag mismatch — data tampered or wrong key.", ex ); + } + } + + /// + /// Encrypts data using AES-256-GCM with a password-derived key. + /// The password is hashed with SHA-512 and truncated to 32 bytes. + /// Output format: nonce (12) ‖ tag (16) ‖ ciphertext. + /// + /// The data to encrypt. + /// The password used to derive the AES key. + /// Combined bytes: nonce + tag + ciphertext. + /// Thrown when encryption fails. + public static byte[] AesGcmPasswordEncrypt( byte[] plaintext, string password ) { + byte[] fullHash = SHA512.HashData( System.Text.Encoding.UTF8.GetBytes( password ) ); + byte[] key = fullHash[..AesGcmKeySize]; + + byte[] ciphertext = AesGcmEncrypt( plaintext, key, out byte[] nonce, out byte[] tag ); + + // Output: nonce (12) ‖ tag (16) ‖ ciphertext + byte[] result = new byte[AesGcmNonceSize + AesGcmTagSize + ciphertext.Length]; + Buffer.BlockCopy( nonce, 0, result, 0, AesGcmNonceSize ); + Buffer.BlockCopy( tag, 0, result, AesGcmNonceSize, AesGcmTagSize ); + Buffer.BlockCopy( ciphertext, 0, result, AesGcmNonceSize + AesGcmTagSize, ciphertext.Length ); + + return result; + } + + /// + /// Decrypts data that was encrypted with . + /// Input format: nonce (12) ‖ tag (16) ‖ ciphertext. + /// + /// Combined nonce + tag + ciphertext bytes. + /// The password used during encryption. + /// The decrypted plaintext. + /// Thrown when decryption fails (wrong password or tampered data). + public static byte[] AesGcmPasswordDecrypt( byte[] encryptedData, string password ) { + if (encryptedData.Length < AesGcmNonceSize + AesGcmTagSize) { + throw new WerkrCryptoException( "Encrypted data is too short — expected at least nonce + tag bytes." ); + } + + byte[] fullHash = SHA512.HashData( System.Text.Encoding.UTF8.GetBytes( password ) ); + byte[] key = fullHash[..AesGcmKeySize]; + + byte[] nonce = encryptedData[..AesGcmNonceSize]; + byte[] tag = encryptedData[AesGcmNonceSize..( AesGcmNonceSize + AesGcmTagSize )]; + byte[] ciphertext = encryptedData[( AesGcmNonceSize + AesGcmTagSize )..]; + + return AesGcmDecrypt( ciphertext, key, nonce, tag ); + } + + // --- Hybrid Encryption --- + + /// + /// Hybrid-encrypts data: generates a random AES-256 key, AES-GCM-encrypts the data, + /// then RSA-OAEP-SHA-512 encrypts only the AES key. + /// Output format: rsaEncryptedKey (512) ‖ nonce (12) ‖ tag (16) ‖ ciphertext. + /// + /// The plaintext data to encrypt (no size limit). + /// The recipient's RSA public key. + /// The hybrid-encrypted envelope bytes. + /// Thrown when encryption fails. + public static byte[] HybridEncrypt( byte[] data, RSAParameters recipientPublicKey ) { + byte[] aesKey = GenerateRandomBytes( AesGcmKeySize ); + byte[] ciphertext = AesGcmEncrypt( data, aesKey, out byte[] nonce, out byte[] tag ); + byte[] rsaEncryptedKey = RSAEncrypt( aesKey, recipientPublicKey ); + + // Output: rsaEncryptedKey (512 for RSA-4096) ‖ nonce (12) ‖ tag (16) ‖ ciphertext + byte[] envelope = new byte[rsaEncryptedKey.Length + AesGcmNonceSize + AesGcmTagSize + ciphertext.Length]; + int offset = 0; + Buffer.BlockCopy( rsaEncryptedKey, 0, envelope, offset, rsaEncryptedKey.Length ); + offset += rsaEncryptedKey.Length; + Buffer.BlockCopy( nonce, 0, envelope, offset, AesGcmNonceSize ); + offset += AesGcmNonceSize; + Buffer.BlockCopy( tag, 0, envelope, offset, AesGcmTagSize ); + offset += AesGcmTagSize; + Buffer.BlockCopy( ciphertext, 0, envelope, offset, ciphertext.Length ); + + return envelope; + } + + /// + /// Decrypts a hybrid-encrypted envelope produced by . + /// Input format: rsaEncryptedKey (512) ‖ nonce (12) ‖ tag (16) ‖ ciphertext. + /// + /// The hybrid-encrypted envelope bytes. + /// The recipient's RSA private key. + /// The decrypted plaintext. + /// Thrown when decryption fails. + public static byte[] HybridDecrypt( byte[] envelope, RSAParameters recipientPrivateKey ) { + int minimumLength = RsaEncryptedBlockSize + AesGcmNonceSize + AesGcmTagSize; + if (envelope.Length < minimumLength) { + throw new WerkrCryptoException( $"Hybrid envelope too short — expected at least {minimumLength} bytes, got {envelope.Length}." ); + } + + byte[] rsaEncryptedKey = envelope[..RsaEncryptedBlockSize]; + byte[] nonce = envelope[RsaEncryptedBlockSize..( RsaEncryptedBlockSize + AesGcmNonceSize )]; + byte[] tag = envelope[( RsaEncryptedBlockSize + AesGcmNonceSize )..( RsaEncryptedBlockSize + AesGcmNonceSize + AesGcmTagSize )]; + byte[] ciphertext = envelope[( RsaEncryptedBlockSize + AesGcmNonceSize + AesGcmTagSize )..]; + + byte[] aesKey = RSADecrypt( rsaEncryptedKey, recipientPrivateKey ); + return AesGcmDecrypt( ciphertext, aesKey, nonce, tag ); + } + + // --- Helpers --- + + /// Generates cryptographically secure random bytes. + /// The number of random bytes to generate. + /// An array of random bytes. + public static byte[] GenerateRandomBytes( int count ) { + byte[] bytes = new byte[count]; + RandomNumberGenerator.Fill( bytes ); + return bytes; + } + + /// + /// Serializes the public components of an RSA key (Modulus, Exponent) to UTF-8 JSON bytes. + /// + /// The RSA key parameters (public components used). + /// UTF-8-encoded JSON bytes containing Modulus and Exponent. + public static byte[] SerializePublicKey( RSAParameters key ) { + var publicOnly = new { key.Modulus, key.Exponent }; + return JsonSerializer.SerializeToUtf8Bytes( publicOnly ); + } + + /// + /// Deserializes a public RSA key from UTF-8 JSON bytes produced by . + /// + /// UTF-8-encoded JSON bytes containing Modulus and Exponent. + /// RSA parameters with only public components populated. + /// Thrown when deserialization fails. + public static RSAParameters DeserializePublicKey( byte[] data ) { + try { + JsonDocument doc = JsonDocument.Parse( data ); + byte[]? modulus = doc.RootElement.GetProperty( "Modulus" ).GetBytesFromBase64( ); + byte[]? exponent = doc.RootElement.GetProperty( "Exponent" ).GetBytesFromBase64( ); + return new RSAParameters { Modulus = modulus, Exponent = exponent }; + } catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException) { + throw new WerkrCryptoException( "Failed to deserialize RSA public key from JSON.", ex ); + } + } + + // --- Platform Validation --- + + /// + /// Validates that the current platform supports all required SHA-512 cryptographic operations. + /// Performs a real RSA OAEP SHA-512 encrypt/decrypt round-trip. + /// Throws on failure. + /// Should be called at startup in both Agent and Api. + /// + /// + /// Thrown when RSA OAEP SHA-512 is not supported on this platform. + /// + public static void ValidatePlatformCryptoSupport( ) { + // RSA OAEP SHA-512 round-trip test + try { + using RSA testKey = RSA.Create( 2048 ); + RSAParameters pubKey = testKey.ExportParameters( false ); + RSAParameters privKey = testKey.ExportParameters( true ); + byte[] testData = System.Text.Encoding.UTF8.GetBytes( "werkr-platform-validation" ); + + byte[] encrypted = testKey.Encrypt( testData, RSAEncryptionPadding.OaepSHA512 ); + + using RSA decryptKey = RSA.Create( ); + decryptKey.ImportParameters( privKey ); + byte[] decrypted = decryptKey.Decrypt( encrypted, RSAEncryptionPadding.OaepSHA512 ); + + if (!testData.SequenceEqual( decrypted )) { + throw new WerkrCryptoException( "RSA OAEP SHA-512 round-trip produced mismatched output." ); + } + } catch (CryptographicException ex) { + throw new WerkrCryptoException( + "RSA OAEP with SHA-512 padding is not supported on this platform.", + ex ); + } + } + + /// + /// Computes the SHA-512 hash of the given data. + /// + /// The data to hash. + /// The 64-byte SHA-512 hash. + public static byte[] HashSHA512( byte[] data ) { + return SHA512.HashData( data ); + } + + /// + /// Computes the SHA-512 hash of a UTF-8 string. + /// + /// The string to hash. + /// The hash as a lowercase hex string. + public static string HashSHA512String( string input ) { + byte[] hash = SHA512.HashData( System.Text.Encoding.UTF8.GetBytes( input ) ); + return Convert.ToHexString( hash ); + } + + /// + /// Computes the SHA-256 fingerprint of an RSA public key string. + /// + /// Serialized public key string. + /// Lowercase hex-formatted SHA-256 fingerprint. + public static string ComputeKeyFingerprint( string publicKeyXml ) { + byte[] keyBytes = System.Text.Encoding.UTF8.GetBytes( publicKeyXml ); + byte[] hash = SHA256.HashData( keyBytes ); + return Convert.ToHexString( hash ).ToLowerInvariant( ); + } +} diff --git a/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionData.cs b/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionData.cs new file mode 100644 index 0000000..5db62b2 --- /dev/null +++ b/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionData.cs @@ -0,0 +1,30 @@ +namespace Werkr.Core.Cryptography.KeyInfo; + +/// +/// Holds AES-GCM decryption data: the symmetric key and ordered chunk notes. +/// +public class AesGcmDecryptionData { + /// The 32-byte AES-256-GCM symmetric key. + public byte[] Key { get; set; } = []; + + /// Ordered list of decryption notes (one per encrypted chunk). + public List Notes { get; set; } = []; + + /// Creates a new empty . + public AesGcmDecryptionData( ) { } + + /// Creates a new with validation. + /// The 32-byte AES-256 key. + /// The ordered decryption notes. + /// Thrown when key length is not 32 bytes. + public AesGcmDecryptionData( byte[] key, List notes ) { + if (key.Length != EncryptionProvider.AesGcmKeySize) { + throw new ArgumentException( + $"AES-GCM key must be {EncryptionProvider.AesGcmKeySize} bytes, got {key.Length}.", + nameof( key ) ); + } + + Key = key; + Notes = notes; + } +} diff --git a/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionNote.cs b/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionNote.cs new file mode 100644 index 0000000..9146fa6 --- /dev/null +++ b/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionNote.cs @@ -0,0 +1,41 @@ +namespace Werkr.Core.Cryptography.KeyInfo; + +/// +/// Holds nonce, tag, and order information for a single AES-GCM encrypted chunk. +/// +public class AesGcmDecryptionNote { + /// The 12-byte nonce used for this chunk. + public byte[] Nonce { get; set; } = new byte[EncryptionProvider.AesGcmNonceSize]; + + /// The 16-byte authentication tag for this chunk. + public byte[] Tag { get; set; } = new byte[EncryptionProvider.AesGcmTagSize]; + + /// The order of this chunk in the encrypted data sequence. + public int Order { get; set; } + + /// Creates a new empty . + public AesGcmDecryptionNote( ) { } + + /// Creates a new with validation. + /// The 12-byte nonce. + /// The 16-byte authentication tag. + /// The chunk order index. + /// Thrown when nonce or tag lengths are invalid. + public AesGcmDecryptionNote( byte[] nonce, byte[] tag, int order ) { + if (nonce.Length != EncryptionProvider.AesGcmNonceSize) { + throw new ArgumentException( + $"Nonce must be {EncryptionProvider.AesGcmNonceSize} bytes, got {nonce.Length}.", + nameof( nonce ) ); + } + + if (tag.Length != EncryptionProvider.AesGcmTagSize) { + throw new ArgumentException( + $"Tag must be {EncryptionProvider.AesGcmTagSize} bytes, got {tag.Length}.", + nameof( tag ) ); + } + + Nonce = nonce; + Tag = tag; + Order = order; + } +} diff --git a/src/Werkr.Core/Cryptography/KeyInfo/RSAKeyPair.cs b/src/Werkr.Core/Cryptography/KeyInfo/RSAKeyPair.cs new file mode 100644 index 0000000..456bb33 --- /dev/null +++ b/src/Werkr.Core/Cryptography/KeyInfo/RSAKeyPair.cs @@ -0,0 +1,61 @@ +using System.Security.Cryptography; + +namespace Werkr.Core.Cryptography.KeyInfo; + +/// +/// Holds an RSA key pair (public and private parameters) with key size metadata. +/// +public class RSAKeyPair { + /// The public key parameters. + public RSAParameters PublicKey { get; set; } + + /// The full (private) key parameters. + public RSAParameters PrivateKey { get; set; } + + /// Key size in bits. + public int KeySize { get; set; } = 4096; + + /// + /// Creates an empty RSA key pair. Use + /// to generate a populated key pair. + /// + public RSAKeyPair( ) { } + + /// + /// Creates an RSA key pair with the specified parameters. + /// + /// The RSA public key parameters. + /// The RSA private key parameters (includes private components). + /// The key size in bits. Must be at least 2048 and divisible by 8. + /// Thrown when key size is invalid. + public RSAKeyPair( RSAParameters publicKey, RSAParameters privateKey, int keySize = 4096 ) { + if (keySize < 2048) { + throw new ArgumentOutOfRangeException( nameof( keySize ), keySize, "RSA key size must be at least 2048 bits." ); + } + + if (keySize % 8 != 0) { + throw new ArgumentOutOfRangeException( nameof( keySize ), keySize, "RSA key size must be divisible by 8." ); + } + + PublicKey = publicKey; + PrivateKey = privateKey; + KeySize = keySize; + } + + /// + /// Gets the public key serialized as UTF-8 JSON bytes (Modulus + Exponent). + /// + /// UTF-8-encoded JSON bytes. + public byte[] GetPublicKeyBytes( ) { + return EncryptionProvider.SerializePublicKey( PublicKey ); + } + + /// + /// Deserializes a public RSA key from UTF-8 JSON bytes. + /// + /// UTF-8-encoded JSON bytes containing Modulus and Exponent. + /// The deserialized RSA public key parameters. + public static RSAParameters FromPublicKeyBytes( byte[] data ) { + return EncryptionProvider.DeserializePublicKey( data ); + } +} diff --git a/src/Werkr.Core/Cryptography/WerkrCryptoException.cs b/src/Werkr.Core/Cryptography/WerkrCryptoException.cs new file mode 100644 index 0000000..02ee1e0 --- /dev/null +++ b/src/Werkr.Core/Cryptography/WerkrCryptoException.cs @@ -0,0 +1,17 @@ +namespace Werkr.Core.Cryptography; + +/// +/// Exception type for all Werkr cryptographic operation failures. +/// Wraps platform-level +/// with clear, actionable error messages. +/// +public class WerkrCryptoException : Exception { + /// Creates a new with the specified message. + /// A clear description of what went wrong. + public WerkrCryptoException( string message ) : base( message ) { } + + /// Creates a new with the specified message and inner exception. + /// A clear description of what went wrong. + /// The underlying exception that caused this failure. + public WerkrCryptoException( string message, Exception inner ) : base( message, inner ) { } +} diff --git a/src/Werkr.Core/Health/AgentHealthCheckService.cs b/src/Werkr.Core/Health/AgentHealthCheckService.cs new file mode 100644 index 0000000..85f6b5a --- /dev/null +++ b/src/Werkr.Core/Health/AgentHealthCheckService.cs @@ -0,0 +1,154 @@ +using Grpc.Core; +using Grpc.Net.Client; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using Werkr.Common.Models; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Core.Health; + +/// +/// Background service that periodically probes every non-revoked agent via +/// the encrypted ConnectionManagement.Heartbeat RPC and updates +/// and +/// in the database. Replaces the former Grpc.Health.V1 probe. +/// +/// Service scope factory for per-sweep database contexts. +/// Singleton gRPC channel cache. +/// Logger for diagnostics. +/// How often to sweep all agents (default: 60 seconds). +public class AgentHealthCheckService( + IServiceScopeFactory scopeFactory, + AgentConnectionManager connectionManager, + ILogger logger, + TimeSpan? interval = null +) : BackgroundService { + private readonly TimeSpan _interval = interval ?? TimeSpan.FromSeconds( 60 ); + + /// + protected override async Task ExecuteAsync( CancellationToken stoppingToken ) { + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "AgentHealthCheckService started. Sweep interval: {Interval}.", _interval ); + } + + while (!stoppingToken.IsCancellationRequested) { + try { + await SweepAsync( stoppingToken ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + logger.LogError( ex, "Error in AgentHealthCheckService sweep." ); + } + + await Task.Delay( _interval, stoppingToken ); + } + } + + private async Task SweepAsync( CancellationToken ct ) { + using IServiceScope scope = scopeFactory.CreateScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + // Get all non-revoked server-side agents + List agents = await dbContext.RegisteredConnections + .Where( c => c.IsServer && c.Status != ConnectionStatus.Revoked ) + .ToListAsync( ct ); + + if (agents.Count == 0) { + return; + } + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "AgentHealthCheckService sweeping {Count} agents.", agents.Count ); + } + + foreach (RegisteredConnection agent in agents) { + ct.ThrowIfCancellationRequested( ); + await ProbeAgentAsync( agent, dbContext, ct ); + } + } + + private async Task ProbeAgentAsync( RegisteredConnection agent, WerkrDbContext dbContext, CancellationToken ct ) { + try { + (GrpcChannel channel, RegisteredConnection resolved) = + await connectionManager.GetChannelAsync( agent.Id, ct ); + + string keyId = resolved.ActiveKeyId ?? resolved.Id.ToString( ); + + // Build and encrypt heartbeat request + HeartbeatRequest heartbeat = new( ) { + AgentVersion = string.Empty, // Server doesn't know the agent version; agent fills response + UptimeSeconds = 0, + ActiveScheduleCount = 0, + StatusMessage = "health-probe", + }; + EncryptedEnvelope requestEnvelope = PayloadEncryptor.EncryptToEnvelope( + heartbeat, resolved.SharedKey, keyId ); + + ConnectionManagement.ConnectionManagementClient client = new( channel ); + EncryptedEnvelope responseEnvelope = await client.HeartbeatAsync( + requestEnvelope, + AgentConnectionManager.CreateCallOptions( + resolved, + timeout: TimeSpan.FromSeconds( 5 ), + cancellationToken: ct ) ); + + // Decrypt to confirm the agent can handle our shared key + HeartbeatResponse response = PayloadEncryptor.DecryptFromEnvelope( + responseEnvelope, resolved.SharedKey ); + + // Agent is reachable and encryption is valid — mark Connected and update LastSeen + if (agent.Status != ConnectionStatus.Connected) { + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Agent {AgentId} ({Name}) transitioned from {OldStatus} to Connected.", + agent.Id, agent.ConnectionName, agent.Status ); + } + agent.Status = ConnectionStatus.Connected; + } + agent.LastSeen = DateTime.UtcNow; + + // Grace period cleanup: if the heartbeat succeeded with the current key, + // the agent has confirmed the key rotation. Clear the previous key. + if (agent.PreviousSharedKey is not null) { + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Clearing PreviousSharedKey for Agent {AgentId} ({Name}). " + + "Heartbeat confirmed current key is active (PreviousKeyId={PreviousKeyId}).", + agent.Id, agent.ConnectionName, agent.PreviousKeyId ); + } + agent.PreviousSharedKey = null; + agent.PreviousKeyId = null; + } + + _ = await dbContext.SaveChangesAsync( ct ); + } catch (OperationCanceledException) { + throw; + } catch (RpcException ex) { + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( + "Agent {AgentId} ({Name}) unreachable: {Status}.", + agent.Id, agent.ConnectionName, ex.StatusCode ); + } + + // Only transition to Disconnected from Connected/Error + if (agent.Status is ConnectionStatus.Connected or ConnectionStatus.Error) { + agent.Status = ConnectionStatus.Disconnected; + _ = await dbContext.SaveChangesAsync( ct ); + } + } catch (Exception ex) { + logger.LogWarning( ex, + "Unexpected error probing Agent {AgentId} ({Name}).", + agent.Id, agent.ConnectionName ); + + if (agent.Status == ConnectionStatus.Connected) { + agent.Status = ConnectionStatus.Error; + _ = await dbContext.SaveChangesAsync( ct ); + } + } + } +} diff --git a/src/Werkr.Core/Operators/ActionOperatorResult.cs b/src/Werkr.Core/Operators/ActionOperatorResult.cs new file mode 100644 index 0000000..230fd36 --- /dev/null +++ b/src/Werkr.Core/Operators/ActionOperatorResult.cs @@ -0,0 +1,10 @@ +namespace Werkr.Core.Operators; + +/// +/// Result of a built-in action operator execution. +/// +/// Whether the action completed successfully. Defaults to true. +/// Optional exception if the action failed. +public sealed record ActionOperatorResult( + bool Success = true, + Exception? Exception = null ) : IOperatorResult; diff --git a/src/Werkr.Core/Operators/IActionHandler.cs b/src/Werkr.Core/Operators/IActionHandler.cs new file mode 100644 index 0000000..69aa381 --- /dev/null +++ b/src/Werkr.Core/Operators/IActionHandler.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using System.Threading.Channels; + +using Werkr.Core.Communication; + +namespace Werkr.Core.Operators; + +/// +/// Interface for individual action handler implementations. +/// Each handler is responsible for a single action type identified +/// by the string property. The +/// action operator implementation uses this property to build a +/// string-keyed handler registry at construction time. +/// +public interface IActionHandler { + /// + /// The action name this handler serves (e.g. "CopyFile", "StartProcess"). + /// Must be unique across all registered handlers — duplicates are caught at DI construction time. + /// + string Action { get; } + + /// + /// Executes the action with the given JSON parameters, writing progress + /// and status messages to as the operation proceeds. + /// + /// + /// JSON element containing the action-specific parameter record. + /// The handler deserializes this into its own typed parameter record. + /// + /// + /// Channel writer for streaming structured output lines back to the caller. + /// + /// Cancellation token for timeout/cancellation support. + /// + /// An indicating success or failure. + /// + Task ExecuteAsync( + JsonElement parameters, + ChannelWriter output, + CancellationToken cancellationToken = default ); +} diff --git a/src/Werkr.Core/Operators/IActionOperator.cs b/src/Werkr.Core/Operators/IActionOperator.cs new file mode 100644 index 0000000..92c7f32 --- /dev/null +++ b/src/Werkr.Core/Operators/IActionOperator.cs @@ -0,0 +1,25 @@ +using Werkr.Common.Models.Actions; + +namespace Werkr.Core.Operators; + +/// +/// Interface for the built-in action operator. +/// Dispatches requests to the appropriate +/// and returns a streaming +/// result identical in shape to +/// , so the entire downstream pipeline +/// (output streaming, success criteria) works without changes. +/// +public interface IActionOperator { + /// + /// Executes a built-in action described by . + /// + /// + /// The action descriptor containing the action name string and JSON parameters. + /// + /// Cancellation token for timeout/cancellation support. + /// + /// An containing streamed output and a typed result. + /// + OperatorExecution Execute( ActionDescriptor descriptor, CancellationToken cancellationToken = default ); +} diff --git a/src/Werkr.Core/Operators/IOperatorResult.cs b/src/Werkr.Core/Operators/IOperatorResult.cs new file mode 100644 index 0000000..28508d0 --- /dev/null +++ b/src/Werkr.Core/Operators/IOperatorResult.cs @@ -0,0 +1,18 @@ +namespace Werkr.Core.Operators; + +/// +/// Common interface for operator execution results. +/// Provides a uniform way to inspect success/failure status across +/// all operator types (PowerShell, System Shell, Action). +/// +public interface IOperatorResult { + /// Whether the operator execution completed successfully. + bool Success { get; } + + /// + /// Optional exception captured during execution. + /// Null when execution succeeds or when the failure is represented + /// by operator-specific properties (e.g., exit code). + /// + Exception? Exception { get; } +} diff --git a/src/Werkr.Core/Operators/IShellOperator.cs b/src/Werkr.Core/Operators/IShellOperator.cs new file mode 100644 index 0000000..fae29c5 --- /dev/null +++ b/src/Werkr.Core/Operators/IShellOperator.cs @@ -0,0 +1,31 @@ +namespace Werkr.Core.Operators; + +/// +/// Interface for shell-based operators (PowerShell and system shell). +/// All methods return for transport-agnostic streaming +/// with a typed result available after the stream completes. +/// Implementations live in Werkr.Agent; this interface lives in Werkr.Core for cross-project accessibility. +/// +public interface IShellOperator { + /// Runs a single command string and yields output as it becomes available. + /// The command to execute. + /// Cancellation token for timeout/cancellation support. + /// An containing streamed output and a typed result. + OperatorExecution RunCommand( string command, CancellationToken cancellationToken = default ); + + /// Runs a script file and yields output as it becomes available. + /// Path to the script file. + /// Cancellation token for timeout/cancellation support. + /// An containing streamed output and a typed result. + OperatorExecution RunScript( string scriptPath, CancellationToken cancellationToken = default ); + + /// Runs a script file with arguments and yields output as it becomes available. + /// Path to the script file. + /// Arguments to pass to the script. + /// Cancellation token for timeout/cancellation support. + /// An containing streamed output and a typed result. + OperatorExecution RunScriptWithArgs( string scriptPath, IEnumerable args, CancellationToken cancellationToken = default ); + + /// Indicates whether this operator is available on the current platform. + bool IsAvailable { get; } +} diff --git a/src/Werkr.Core/Operators/OperatorExecution.cs b/src/Werkr.Core/Operators/OperatorExecution.cs new file mode 100644 index 0000000..3cb0bdf --- /dev/null +++ b/src/Werkr.Core/Operators/OperatorExecution.cs @@ -0,0 +1,16 @@ +using Werkr.Core.Communication; + +namespace Werkr.Core.Operators; + +/// +/// The result of a shell operator execution, containing both the streamed output +/// and the final typed result with exit code / error information. +/// +/// Asynchronous stream of operator output lines. +/// +/// A task that completes when execution finishes, providing the typed result. +/// Callers should consume first, then await . +/// +public sealed record OperatorExecution( + IAsyncEnumerable Output, + Task Result ); diff --git a/src/Werkr.Core/Operators/PwshOperatorResult.cs b/src/Werkr.Core/Operators/PwshOperatorResult.cs new file mode 100644 index 0000000..a5b874d --- /dev/null +++ b/src/Werkr.Core/Operators/PwshOperatorResult.cs @@ -0,0 +1,22 @@ +namespace Werkr.Core.Operators; + +/// +/// Result of a PowerShell operator execution. +/// +/// Whether the PowerShell runtime reported non-terminating errors. +/// +/// Value of $LASTEXITCODE after execution, if a native command was invoked. +/// Null when no native command ran during the session. +/// +/// Optional exception from a terminating error or infrastructure failure. +public sealed record PwshOperatorResult( + bool HadErrors, + int? LastExitCode, + Exception? Exception = null ) : IOperatorResult { + + /// + /// Success is determined by the absence of errors from the PowerShell runtime. + /// This is the default criterion; tasks may override with custom SuccessCriteria. + /// + public bool Success => !HadErrors && Exception is null; +} diff --git a/src/Werkr.Core/Operators/ShellOperatorResult.cs b/src/Werkr.Core/Operators/ShellOperatorResult.cs new file mode 100644 index 0000000..13681e0 --- /dev/null +++ b/src/Werkr.Core/Operators/ShellOperatorResult.cs @@ -0,0 +1,17 @@ +namespace Werkr.Core.Operators; + +/// +/// Result of a system shell operator execution (cmd.exe / bash). +/// +/// The process exit code. Zero typically indicates success. +/// Optional exception from a process launch failure or infrastructure error. +public sealed record ShellOperatorResult( + int ExitCode, + Exception? Exception = null ) : IOperatorResult { + + /// + /// Success is determined by a zero exit code and no exception. + /// This is the default criterion; tasks may override with custom SuccessCriteria. + /// + public bool Success => ExitCode == 0 && Exception is null; +} diff --git a/src/Werkr.Core/Registration/BundleExpirationService.cs b/src/Werkr.Core/Registration/BundleExpirationService.cs new file mode 100644 index 0000000..06fd9a3 --- /dev/null +++ b/src/Werkr.Core/Registration/BundleExpirationService.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using Werkr.Common.Models; +using Werkr.Data; + +namespace Werkr.Core.Registration; + +/// +/// Background service that automatically transitions expired registration bundles +/// from to . +/// Runs every hour by default. +/// +/// +/// Creates a new . +/// +/// Service scope factory for creating database contexts. +/// Logger for diagnostics. +/// How often to check for expired bundles (default: 1 hour). +public class BundleExpirationService( + IServiceScopeFactory scopeFactory, + ILogger logger, + TimeSpan? interval = null +) : BackgroundService { + private readonly TimeSpan _interval = interval ?? TimeSpan.FromHours( 1 ); + + /// + protected override async Task ExecuteAsync( CancellationToken stoppingToken ) { + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "BundleExpirationService started. Checking every {Interval}.", _interval ); + } + + while (!stoppingToken.IsCancellationRequested) { + try { + await ExpireStaleBundlesAsync( stoppingToken ); + } catch (Exception ex) when (ex is not OperationCanceledException) { + logger.LogError( ex, "Error in BundleExpirationService." ); + } + + await Task.Delay( _interval, stoppingToken ); + } + } + + private async Task ExpireStaleBundlesAsync( CancellationToken ct ) { + using IServiceScope scope = scopeFactory.CreateScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + DateTime now = DateTime.UtcNow; + + List staleBundles = await dbContext.RegistrationBundles + .Where( b => b.Status == RegistrationStatus.Pending && b.ExpiresAt < now ) + .ToListAsync( ct ); + + if (staleBundles.Count == 0) { + return; + } + + foreach (Data.Entities.Registration.RegistrationBundle bundle in staleBundles) { + bundle.Status = RegistrationStatus.Expired; + } + + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Expired {Count} stale registration bundle(s).", staleBundles.Count ); + } + } +} diff --git a/src/Werkr.Core/Registration/Models/AgentRegistrationResult.cs b/src/Werkr.Core/Registration/Models/AgentRegistrationResult.cs new file mode 100644 index 0000000..603973a --- /dev/null +++ b/src/Werkr.Core/Registration/Models/AgentRegistrationResult.cs @@ -0,0 +1,14 @@ +namespace Werkr.Core.Registration.Models; + +/// +/// Result returned to the Agent after a registration attempt completes. +/// +/// Whether the registration succeeded. +/// The raw API key (Agent stores this). Null on failure. +/// The 32-byte pre-shared AES-256 symmetric key. Null on failure. +/// Error description if registration failed; success message otherwise. +public sealed record AgentRegistrationResult( + bool Success, + string? ApiKey, + byte[]? SharedKey, + string? ErrorMessage ); diff --git a/src/Werkr.Core/Registration/Models/RegistrationBundlePayload.cs b/src/Werkr.Core/Registration/Models/RegistrationBundlePayload.cs new file mode 100644 index 0000000..9ef6257 --- /dev/null +++ b/src/Werkr.Core/Registration/Models/RegistrationBundlePayload.cs @@ -0,0 +1,57 @@ +using System.Text.Json; + +using Werkr.Core.Cryptography; + +namespace Werkr.Core.Registration.Models; + +/// +/// The data inside the encrypted registration bundle that the admin carries from Server to Agent. +/// Contains the Server's public key, connection metadata, and the correlation token. +/// +/// 16-byte random correlation token identifying the pending registration. +/// Admin-assigned label for this Agent connection. +/// The Server's gRPC endpoint URL that the Agent will call back. +/// Serialized RSA public key bytes (via ). +public sealed record RegistrationBundlePayload( + byte[] BundleId, + string ConnectionName, + string ServerUrl, + byte[] ServerPublicKeyBytes ) { + + /// + /// Serializes this payload to JSON, encrypts it with a password via AES-GCM, + /// and returns the result as a Base64-encoded string. + /// + /// The password to encrypt the bundle with. + /// A Base64-encoded encrypted string. + public string ToEncryptedString( string password ) { + byte[] json = JsonSerializer.SerializeToUtf8Bytes( this ); + byte[] encrypted = EncryptionProvider.AesGcmPasswordEncrypt( json, password ); + return Convert.ToBase64String( encrypted ); + } + + /// + /// Decrypts and deserializes a from a Base64-encoded encrypted string. + /// + /// The Base64-encoded encrypted string produced by . + /// The password used during encryption. + /// The deserialized payload. + /// Thrown when the input is null or empty. + /// Thrown when decryption fails (wrong password or corrupted data). + public static RegistrationBundlePayload FromEncryptedString( string encrypted, string password ) { + if (string.IsNullOrWhiteSpace( encrypted )) { + throw new ArgumentException( "Encrypted bundle string cannot be null or empty.", nameof( encrypted ) ); + } + + byte[] encryptedBytes; + try { + encryptedBytes = Convert.FromBase64String( encrypted ); + } catch (FormatException ex) { + throw new WerkrCryptoException( "Invalid Base64 format in encrypted bundle string.", ex ); + } + + byte[] json = EncryptionProvider.AesGcmPasswordDecrypt( encryptedBytes, password ); + RegistrationBundlePayload? payload = JsonSerializer.Deserialize( json ); + return payload ?? throw new WerkrCryptoException( "Failed to deserialize registration bundle payload." ); + } +} diff --git a/src/Werkr.Core/Registration/Models/RegistrationResponsePayload.cs b/src/Werkr.Core/Registration/Models/RegistrationResponsePayload.cs new file mode 100644 index 0000000..3cab812 --- /dev/null +++ b/src/Werkr.Core/Registration/Models/RegistrationResponsePayload.cs @@ -0,0 +1,16 @@ +namespace Werkr.Core.Registration.Models; + +/// +/// Data hybrid-encrypted in the Server's gRPC response during registration. +/// Contains bidirectional API keys and the pre-shared symmetric key. +/// Serialized to JSON, then hybrid-encrypted with the Agent's RSA public key. +/// +/// The raw API key for the Agent to call the Server. Agent stores as OutboundApiKey. +/// The raw API key for the Server to call the Agent. Agent stores hash as InboundApiKeyHash. +/// The 32-byte AES-256 symmetric key for ongoing message encryption. +/// The shared connection ID both sides use for post-registration communication. +public sealed record RegistrationResponsePayload( + string AgentToServerApiKey, + string ServerToAgentApiKey, + byte[] SharedKey, + Guid ConnectionId ); diff --git a/src/Werkr.Core/Registration/RegistrationBundleGenerator.cs b/src/Werkr.Core/Registration/RegistrationBundleGenerator.cs new file mode 100644 index 0000000..231ca32 --- /dev/null +++ b/src/Werkr.Core/Registration/RegistrationBundleGenerator.cs @@ -0,0 +1,66 @@ +using Werkr.Core.Cryptography; +using Werkr.Core.Registration.Models; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Core.Registration; + +/// +/// Creates encrypted registration bundles for admin-carried handoff to Agents. +/// +public static class RegistrationBundleGenerator { + /// + /// Creates an encrypted registration bundle and its corresponding database entity. + /// The admin copies the encrypted string and carries it to the Agent's localhost registration page. + /// + /// Admin-assigned label for this Agent connection. + /// The Server's gRPC endpoint URL that the Agent will call back. + /// Password used to AES-GCM encrypt the bundle. + /// RSA key size in bits (default 4096). + /// How long the bundle stays valid (default 24 hours). + /// Tuple of the encrypted bundle string and the entity to persist. + public static (string EncryptedBundle, RegistrationBundle Entity) CreateBundle( + string connectionName, + string serverUrl, + string password, + int keySize = 4096, + TimeSpan? expiration = null ) { + + // Generate RSA key pair for this registration + Cryptography.KeyInfo.RSAKeyPair keyPair = EncryptionProvider.GenerateRSAKeyPair( keySize ); + + // Generate random 16-byte correlation token + byte[] bundleId = EncryptionProvider.GenerateRandomBytes( 16 ); + + // Serialize server's public key + byte[] serverPublicKeyBytes = EncryptionProvider.SerializePublicKey( keyPair.PublicKey ); + + // Build payload + RegistrationBundlePayload payload = new( + BundleId: bundleId, + ConnectionName: connectionName, + ServerUrl: serverUrl, + ServerPublicKeyBytes: serverPublicKeyBytes ); + + // Encrypt payload with password + string encryptedBundle = payload.ToEncryptedString( password ); + + // Create entity + DateTime expiresAt = expiration switch { + null => DateTime.UtcNow + TimeSpan.FromHours( 24 ), + TimeSpan timeSpan when timeSpan <= TimeSpan.Zero => DateTime.MaxValue, + TimeSpan timeSpan => DateTime.UtcNow + timeSpan + }; + + RegistrationBundle entity = new( ) { + ConnectionName = connectionName, + ServerPublicKey = keyPair.PublicKey, + ServerPrivateKey = keyPair.PrivateKey, + BundleId = bundleId, + Status = Common.Models.RegistrationStatus.Pending, + ExpiresAt = expiresAt, + KeySize = keySize, + }; + + return (encryptedBundle, entity); + } +} diff --git a/src/Werkr.Core/Registration/RegistrationService.cs b/src/Werkr.Core/Registration/RegistrationService.cs new file mode 100644 index 0000000..7898142 --- /dev/null +++ b/src/Werkr.Core/Registration/RegistrationService.cs @@ -0,0 +1,157 @@ +using System.Security.Cryptography; +using System.Text.Json; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Werkr.Common.Models; +using Werkr.Core.Cryptography; +using Werkr.Core.Registration.Models; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Core.Registration; + +/// +/// Orchestrates the full server-side registration flow: bundle generation and completion. +/// +/// +/// Creates a new . +/// +/// The database context for persisting registration data. +/// Logger for diagnostics. +/// The Server's gRPC endpoint URL embedded in bundles. +public class RegistrationService( + WerkrDbContext dbContext, + ILogger logger, + string serverUrl +) { + + /// + /// Generates an encrypted registration bundle, persists it to the database, + /// and returns the encrypted string for the admin to copy. + /// + /// Admin-assigned label for this Agent connection. + /// Password to encrypt the bundle. + /// Optional custom expiration timespan. + /// Optional tags to assign to the agent upon registration completion. + /// Cancellation token. + /// The encrypted bundle string (Base64-encoded). + public async Task GenerateBundleAsync( + string connectionName, + string password, + TimeSpan? expiration, + string[]? tags = null, + CancellationToken ct = default ) { + + (string encryptedBundle, RegistrationBundle entity) = RegistrationBundleGenerator.CreateBundle( + connectionName, serverUrl, password, expiration: expiration ); + + // Carry tags through to completion + entity.Tags = tags ?? []; + + _ = dbContext.RegistrationBundles.Add( entity ); + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Registration bundle created for '{ConnectionName}', expires at {ExpiresAt}.", + connectionName, entity.ExpiresAt ); + } + + return encryptedBundle; + } + + /// + /// Called when the Agent's gRPC RegisterAgent request arrives. + /// Validates the bundle, decrypts the Agent's public key, generates API key and shared key, + /// creates a , and returns the encrypted credentials. + /// + /// The 16-byte correlation token from the Agent's request. + /// The Agent's RSA public key, hybrid-encrypted with Server's public key. + /// The Agent's gRPC endpoint URL. + /// Human-readable Agent name. + /// Cancellation token. + /// An indicating success or failure. + public async Task<(AgentRegistrationResult Result, byte[]? EncryptedResponseData)> CompleteRegistrationAsync( + byte[] bundleId, + byte[] encryptedAgentPublicKey, + string agentUrl, + string agentName, + CancellationToken ct ) { + + // Look up bundle by BundleId + RegistrationBundle? bundle = await dbContext.RegistrationBundles + .FirstOrDefaultAsync( b => b.BundleId == bundleId, ct ); + + if (bundle is null) { + logger.LogWarning( "Registration attempt with unknown BundleId." ); + return (new AgentRegistrationResult( false, null, null, "Unknown registration bundle." ), null); + } + + if (bundle.Status != RegistrationStatus.Pending) { + logger.LogWarning( "Registration attempt on non-pending bundle {BundleId}, status: {Status}.", + Convert.ToHexString( bundleId ), bundle.Status ); + return (new AgentRegistrationResult( false, null, null, $"Bundle is not pending (status: {bundle.Status})." ), null); + } + + if (bundle.ExpiresAt <= DateTime.UtcNow) { + bundle.Status = RegistrationStatus.Expired; + _ = await dbContext.SaveChangesAsync( ct ); + logger.LogWarning( "Expired bundle used for registration attempt." ); + return (new AgentRegistrationResult( false, null, null, "Registration bundle has expired." ), null); + } + + try { + // Hybrid-decrypt Agent's public key + byte[] agentPublicKeyBytes = EncryptionProvider.HybridDecrypt( encryptedAgentPublicKey, bundle.ServerPrivateKey ); + RSAParameters agentPublicKey = EncryptionProvider.DeserializePublicKey( agentPublicKeyBytes ); + + // Generate bidirectional API keys: 128-char hex strings (64 random bytes each) + string agentToServerKey = Convert.ToHexString( EncryptionProvider.GenerateRandomBytes( 64 ) ); + string serverToAgentKey = Convert.ToHexString( EncryptionProvider.GenerateRandomBytes( 64 ) ); + + // Generate pre-shared symmetric key: 32-byte AES-256 key + byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); + + // Create RegisteredConnection (Server side) + // Server stores: OutboundApiKey = raw Server→Agent key, InboundApiKeyHash = hash of Agent→Server key + RegisteredConnection connection = new( ) { + ConnectionName = bundle.ConnectionName, + RemoteUrl = agentUrl, + LocalPublicKey = bundle.ServerPublicKey, + LocalPrivateKey = bundle.ServerPrivateKey, + RemotePublicKey = agentPublicKey, + OutboundApiKey = serverToAgentKey, + InboundApiKeyHash = EncryptionProvider.HashSHA512String( agentToServerKey ), + SharedKey = sharedKey, + IsServer = true, + Status = ConnectionStatus.Connected, + Tags = bundle.Tags, + }; + + _ = dbContext.RegisteredConnections.Add( connection ); + bundle.Status = RegistrationStatus.Completed; + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Registration completed for '{ConnectionName}' with Agent '{AgentName}' at {AgentUrl}.", + bundle.ConnectionName, agentName, agentUrl ); + } + + // Build encrypted response payload with bidirectional keys and shared connection ID + RegistrationResponsePayload responsePayload = new( agentToServerKey, serverToAgentKey, sharedKey, connection.Id ); + byte[] responseJson = JsonSerializer.SerializeToUtf8Bytes( responsePayload ); + byte[] encryptedResponseData = EncryptionProvider.HybridEncrypt( responseJson, agentPublicKey ); + + AgentRegistrationResult result = new( true, agentToServerKey, sharedKey, + $"Registration complete. Connection '{bundle.ConnectionName}' established." ); + + return (result, encryptedResponseData); + } catch (WerkrCryptoException ex) { + logger.LogError( ex, "Cryptographic error during registration completion." ); + return (new AgentRegistrationResult( false, null, null, "Registration failed: cryptographic error — " + ex.Message ), null); + } + } +} diff --git a/src/Werkr.Core/Scheduling/HolidayCalculator.cs b/src/Werkr.Core/Scheduling/HolidayCalculator.cs new file mode 100644 index 0000000..0ec7cd8 --- /dev/null +++ b/src/Werkr.Core/Scheduling/HolidayCalculator.cs @@ -0,0 +1,153 @@ +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Core.Scheduling; + +/// +/// Static computation engine that evaluates definitions +/// into concrete instances for given year(s). +/// +public static class HolidayCalculator { + /// + /// Evaluates a single rule for a specific year and returns any resulting holiday dates. + /// Returns an empty list if the year is outside the rule's YearStart/YearEnd bounds. + /// + public static IReadOnlyList ComputeDatesForYear( HolidayRule rule, int year ) { + // Year bounds check + if (rule.YearStart.HasValue && year < rule.YearStart.Value) { + return []; + } + + if (rule.YearEnd.HasValue && year > rule.YearEnd.Value) { + return []; + } + + DateOnly? rawDate = rule.RuleType switch { + HolidayRuleType.FixedDate => ComputeFixedDate( rule, year ), + HolidayRuleType.NthWeekdayOfMonth => ComputeNthWeekday( rule, year ), + HolidayRuleType.LastWeekdayOfMonth => ComputeLastWeekday( rule, year ), + _ => null, + }; + + if (rawDate is null) { + return []; + } + + // Apply observance rule (weekend shifting) for FixedDate rules + DateOnly observed = rule.RuleType == HolidayRuleType.FixedDate + ? ApplyObservanceRule( rawDate.Value, rule.ObservanceRule ) + : rawDate.Value; + + return [ + new HolidayDate { + HolidayCalendarId = rule.HolidayCalendarId, + HolidayRuleId = rule.Id, + Date = observed, + Name = rule.Name, + Year = year, + WindowStart = rule.WindowStart, + WindowEnd = rule.WindowEnd, + WindowTimeZoneId = rule.WindowTimeZoneId, + } + ]; + } + + /// + /// Evaluates all rules in a calendar for a specific year. + /// + public static IReadOnlyList ComputeAllDatesForYear( HolidayCalendar calendar, int year ) { + List results = []; + foreach (HolidayRule rule in calendar.Rules) { + results.AddRange( ComputeDatesForYear( rule, year ) ); + } + return results; + } + + /// + /// Batch computation across a year range (inclusive). + /// + public static IReadOnlyList ComputeDatesForRange( HolidayCalendar calendar, int startYear, int endYear ) { + List results = []; + for (int year = startYear; year <= endYear; year++) { + results.AddRange( ComputeAllDatesForYear( calendar, year ) ); + } + return results; + } + + /// + /// Applies the observance rule to shift a holiday that falls on a weekend. + /// + internal static DateOnly ApplyObservanceRule( DateOnly date, ObservanceRule rule ) => rule switch { + ObservanceRule.None => date, + ObservanceRule.SaturdayToFriday_SundayToMonday => date.DayOfWeek switch { + DayOfWeek.Saturday => date.AddDays( -1 ), + DayOfWeek.Sunday => date.AddDays( 1 ), + _ => date, + }, + ObservanceRule.SaturdayToMonday => date.DayOfWeek switch { + DayOfWeek.Saturday => date.AddDays( 2 ), + _ => date, + }, + ObservanceRule.NearestWeekday => date.DayOfWeek switch { + DayOfWeek.Saturday => date.AddDays( -1 ), + DayOfWeek.Sunday => date.AddDays( 1 ), + _ => date, + }, + _ => date, + }; + + /// + /// Returns the Nth occurrence of a weekday in a given month, or null if there aren't enough. + /// + internal static DateOnly? GetNthWeekdayOfMonth( int year, int month, DayOfWeek day, int n ) { + DateOnly first = new( year, month, 1 ); + int daysUntilTarget = ((int) day - (int) first.DayOfWeek + 7) % 7; + DateOnly firstOccurrence = first.AddDays( daysUntilTarget ); + DateOnly nthOccurrence = firstOccurrence.AddDays( 7 * (n - 1) ); + + // Verify we're still in the same month + return nthOccurrence.Month == month ? nthOccurrence : null; + } + + /// + /// Returns the last occurrence of a weekday in a given month. + /// + internal static DateOnly GetLastWeekdayOfMonth( int year, int month, DayOfWeek day ) { + int daysInMonth = DateTime.DaysInMonth( year, month ); + DateOnly last = new( year, month, daysInMonth ); + int daysBack = ((int) last.DayOfWeek - (int) day + 7) % 7; + return last.AddDays( -daysBack ); + } + + /// Fixed date with observance shift. + private static DateOnly? ComputeFixedDate( HolidayRule rule, int year ) { + if (!rule.Month.HasValue || !rule.Day.HasValue) { + return null; + } + + int month = rule.Month.Value; + int day = rule.Day.Value; + + // Handle Feb 29 in non-leap years + if (month == 2 && day == 29 && !DateTime.IsLeapYear( year )) { + return null; + } + + // Validate day for the month + return day > DateTime.DaysInMonth( year, month ) ? null : new DateOnly( year, month, day ); + } + + /// Nth weekday of month. + private static DateOnly? ComputeNthWeekday( HolidayRule rule, int year ) { + return !rule.Month.HasValue || !rule.DayOfWeek.HasValue || !rule.WeekNumber.HasValue + ? null + : GetNthWeekdayOfMonth( year, rule.Month.Value, rule.DayOfWeek.Value, rule.WeekNumber.Value ); + } + + /// Last weekday of month. + private static DateOnly? ComputeLastWeekday( HolidayRule rule, int year ) { + return !rule.Month.HasValue || !rule.DayOfWeek.HasValue + ? null + : GetLastWeekdayOfMonth( year, rule.Month.Value, rule.DayOfWeek.Value ); + } +} diff --git a/src/Werkr.Core/Scheduling/HolidayDateService.cs b/src/Werkr.Core/Scheduling/HolidayDateService.cs new file mode 100644 index 0000000..d1241fd --- /dev/null +++ b/src/Werkr.Core/Scheduling/HolidayDateService.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Werkr.Data; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Core.Scheduling; + +/// +/// Manages materialisation and caching of records +/// generated from definitions in a . +/// +public sealed partial class HolidayDateService( + WerkrDbContext db, + ILogger logger ) { + + /// + /// Computes dates from all rules in a calendar for the given year range and upserts into HolidayDates. + /// Implements merge strategy (Decision H17): when a computed date matches an existing manual entry + /// on (CalendarId, Date, WindowStart), the manual entry's HolidayRuleId is updated. + /// Removes stale computed entries outside the year range. + /// + public async Task MaterializeDatesAsync( Guid calendarId, int startYear, int endYear, CancellationToken ct = default ) { + HolidayCalendar? calendar = await db.HolidayCalendars + .Include( c => c.Rules ) + .FirstOrDefaultAsync( c => c.Id == calendarId, ct ); + + if (calendar is null) { + logger.LogWarning( "Calendar {CalendarId} not found for materialization.", calendarId ); + return; + } + + IReadOnlyList computed = HolidayCalculator.ComputeDatesForRange( calendar, startYear, endYear ); + + // Load existing dates for the year range (both manual and rule-generated) + List existing = await db.HolidayDates + .Where( d => d.HolidayCalendarId == calendarId && d.Year >= startYear && d.Year <= endYear ) + .ToListAsync( ct ); + + foreach (HolidayDate newDate in computed) { + // Look for an existing entry matching (CalendarId, Date, WindowStart) + HolidayDate? match = existing.FirstOrDefault( e => + e.Date == newDate.Date && + e.WindowStart == newDate.WindowStart ); + + if (match is not null) { + // Merge: link manual entry to the generating rule (H17) + match.HolidayRuleId = newDate.HolidayRuleId; + match.Name = newDate.Name; + match.WindowEnd = newDate.WindowEnd; + match.WindowTimeZoneId = newDate.WindowTimeZoneId; + } else { + // Insert new computed date + newDate.HolidayCalendarId = calendarId; + _ = db.HolidayDates.Add( newDate ); + } + } + + _ = await db.SaveChangesAsync( ct ); + LogMaterialized( logger, computed.Count, calendarId, startYear, endYear ); + } + + /// + /// Queries materialized dates for a date range. Auto-materializes if missing years are detected. + /// + public async Task> GetDatesForRangeAsync( + Guid calendarId, DateOnly start, DateOnly end, CancellationToken ct = default ) { + + int startYear = start.Year; + int endYear = end.Year; + + // Check which years have materialized data + List materializedYears = await db.HolidayDates + .Where( d => d.HolidayCalendarId == calendarId && d.HolidayRuleId != null + && d.Year >= startYear && d.Year <= endYear ) + .Select( d => d.Year ) + .Distinct( ) + .ToListAsync( ct ); + + // Auto-materialize missing years + for (int year = startYear; year <= endYear; year++) { + if (!materializedYears.Contains( year )) { + await EnsureMaterializedAsync( calendarId, year, ct ); + } + } + + // Query all dates (manual + rule-generated) in the range + return await db.HolidayDates + .Where( d => d.HolidayCalendarId == calendarId + && d.Date >= start && d.Date <= end ) + .AsNoTracking( ) + .ToListAsync( ct ); + } + + /// + /// Deletes all rule-generated entries for a calendar. + /// Manual entries (HolidayRuleId == null) are preserved. + /// Called on any rule mutation to invalidate the cache. + /// + public async Task InvalidateCacheAsync( Guid calendarId, CancellationToken ct = default ) { + int deleted = await db.HolidayDates + .Where( d => d.HolidayCalendarId == calendarId && d.HolidayRuleId != null ) + .ExecuteDeleteAsync( ct ); + + LogCacheInvalidated( logger, deleted, calendarId ); + } + + /// + /// Materializes a single year if not already present. + /// + public async Task EnsureMaterializedAsync( Guid calendarId, int year, CancellationToken ct = default ) { + bool hasData = await db.HolidayDates + .AnyAsync( d => d.HolidayCalendarId == calendarId + && d.HolidayRuleId != null && d.Year == year, ct ); + + if (!hasData) { + await MaterializeDatesAsync( calendarId, year, year, ct ); + } + } + + [LoggerMessage( Level = LogLevel.Information, + Message = "Materialized {Count} dates for calendar {CalendarId} ({StartYear}-{EndYear})" )] + private static partial void LogMaterialized( ILogger logger, int count, Guid calendarId, int startYear, int endYear ); + + [LoggerMessage( Level = LogLevel.Information, + Message = "Invalidated {Count} cached dates for calendar {CalendarId}" )] + private static partial void LogCacheInvalidated( ILogger logger, int count, Guid calendarId ); +} diff --git a/src/Werkr.Core/Scheduling/ScheduleCalculator.cs b/src/Werkr.Core/Scheduling/ScheduleCalculator.cs new file mode 100644 index 0000000..7e2c295 --- /dev/null +++ b/src/Werkr.Core/Scheduling/ScheduleCalculator.cs @@ -0,0 +1,848 @@ +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Extensions; +using Werkr.Data.Calendar.Models; +using Werkr.Data.Collections; +using Werkr.Data.Entities.Schedule; + +using HolidayCalendarMode = Werkr.Data.Calendar.Enums.HolidayCalendarMode; + +namespace Werkr.Core.Scheduling; + +/// +/// Calculates schedule occurrence DateTimes from a composite model. +/// Ported from the WerkrDotApp reference ScheduleExtensions with name mappings applied. +/// +public static class ScheduleCalculator { + + #region Calculate Occurrences + + /// + /// Calculates the occurrences of this schedule starting from the until the + /// DateTime (or the occurrences reach the schedule Expiration.UtcTime). + /// + /// An ordered read-only list containing all of the DateTimes that the schedule should run within the window. + public static IReadOnlyList CalculateOccurrences( Schedule schedule, DateTime endOfWindow ) { + HashSet result = []; + + if (endOfWindow.Kind != DateTimeKind.Utc) { + throw new InvalidOperationException( "The endOfWindow DateTime must be in UTC." ); + } + + StartDateTimeInfo startDt = schedule.StartDateTime + ?? throw new InvalidOperationException( "Schedule must have a StartDateTime." ); + + DateTime occurrence = startDt.UtcTime; + TimeOnly startTime = TimeOnly.FromDateTime( occurrence ); + + // Add initial StartDateTime UtcTime to result list (if not past expiration) + // then loop through and add any repeat occurrences. + if (AddToResultAndCalculateRepeatOccurrences( + occurrence, + startDt, + schedule.RepeatOptions, + schedule.Expiration, + endOfWindow, + result + )) { return result.OrderBy( dt => dt ).ToList( ).AsReadOnly( ); } + + // Calculate Recurrence Schedules + if (schedule.DailyRecurrence != null) { + CalculateDailyOccurrences( + startDt, + schedule.RepeatOptions, + schedule.Expiration, + endOfWindow, + result, + schedule.DailyRecurrence + ); + } else if (schedule.WeeklyRecurrence != null) { + CalculateWeeklyOccurrences( + startDt, + schedule.RepeatOptions, + schedule.Expiration, + endOfWindow, + result, + schedule.WeeklyRecurrence + ); + } else if (schedule.MonthlyRecurrence != null) { + CalculateMonthlyOccurrences( + occurrence, + startDt, + schedule.RepeatOptions, + schedule.Expiration, + endOfWindow, + result, + schedule.MonthlyRecurrence, + startTime + ); + } + + return result.OrderBy( dt => dt ).ToList( ).AsReadOnly( ); + } + + /// + /// overload for . + /// Converts the to UTC DateTime internally, then wraps + /// each resulting occurrence as a in UTC. + /// + /// The schedule composite model. + /// End of the preview window. + /// An ordered read-only list of occurrence times as (UTC). + public static IReadOnlyList CalculateOccurrences( Schedule schedule, DateTimeOffset endOfWindow ) { + IReadOnlyList utcOccurrences = CalculateOccurrences( schedule, endOfWindow.UtcDateTime ); + return utcOccurrences + .Select( dt => new DateTimeOffset( dt, TimeSpan.Zero ) ) + .ToList( ) + .AsReadOnly( ); + } + + /// + /// Holiday-aware overload. Computes raw occurrences via the existing recurrence algorithms, + /// then applies a post-filter using the supplied holiday dates (Decision H11). + /// + /// The schedule composite model. + /// End of the preview window (UTC). + /// Pre-materialized holiday dates to filter against, or null for no filtering. + /// Blocklist (suppress matches) or Allowlist (keep only matches), or null for no filtering. + /// A with both kept and suppressed occurrences. + public static ScheduleOccurrenceResult CalculateOccurrences( + Schedule schedule, + DateTime endOfWindow, + IReadOnlyList? holidayDates, + HolidayCalendarMode? mode ) { + + IReadOnlyList rawOccurrences = CalculateOccurrences( schedule, endOfWindow ); + + if (holidayDates is null || !holidayDates.Any( ) || mode is null) { + return new ScheduleOccurrenceResult( rawOccurrences, [] ); + } + + List kept = []; + List suppressed = []; + + foreach (DateTime occ in rawOccurrences) { + HolidayDate? matchingHoliday = holidayDates.FirstOrDefault( h => IsOccurrenceOnHoliday( occ, h ) ); + + if (mode == HolidayCalendarMode.Blocklist) { + if (matchingHoliday is not null) { + suppressed.Add( new SuppressedOccurrence( occ, matchingHoliday.Name, + $"Blocked by {matchingHoliday.Name}" ) ); + } else { + kept.Add( occ ); + } + } else /* Allowlist */ { + if (matchingHoliday is not null) { + kept.Add( occ ); + } else { + suppressed.Add( new SuppressedOccurrence( occ, string.Empty, + "Not on an allowed holiday" ) ); + } + } + } + + return new ScheduleOccurrenceResult( kept.AsReadOnly( ), suppressed.AsReadOnly( ) ); + } + + /// + /// Checks whether a UTC occurrence falls on a holiday date, respecting optional time windows. + /// + internal static bool IsOccurrenceOnHoliday( DateTime utcOccurrence, HolidayDate holiday ) { + if (holiday.WindowStart is null || holiday.WindowEnd is null || string.IsNullOrEmpty( holiday.WindowTimeZoneId )) { + // Full-day holiday: compare DateOnly + DateOnly occDate = DateOnly.FromDateTime( utcOccurrence ); + return occDate == holiday.Date; + } + + // Time-window holiday: convert UTC occurrence to holiday's timezone + TimeZoneInfo tz = TimeZoneInfo.FindSystemTimeZoneById( holiday.WindowTimeZoneId ); + DateTime localTime = TimeZoneInfo.ConvertTimeFromUtc( utcOccurrence, tz ); + DateOnly localDate = DateOnly.FromDateTime( localTime ); + + if (localDate != holiday.Date) { + return false; + } + + TimeOnly localTimeOfDay = TimeOnly.FromDateTime( localTime ); + return localTimeOfDay >= holiday.WindowStart.Value && localTimeOfDay <= holiday.WindowEnd.Value; + } + + + #region Repeat, Expiration, and Flow Control + + /// + /// This method is used for three purposes: + /// + /// Checking the UtcTime and exiting early if the + /// is past the Expiration time. (returns true) + /// Adding the non-expired to the list. + /// Calculating any repeat occurrences and repeating the loop if there are repeat occurrences. + /// + /// Under normal operation this method will return false to indicate that the caller should continue processing. + /// + /// Returns true if has reached its + internal static bool AddToResultAndCalculateRepeatOccurrences( + DateTime occurrence, + StartDateTimeInfo startDt, + ScheduleRepeatOptions? repeatOptions, + ExpirationDateTimeInfo? expiration, + DateTime endOfWindow, + HashSet result + ) { + long maxRepeat = 0; + if (repeatOptions != null) { + maxRepeat = (long)Math.Floor( + (decimal)((repeatOptions.RepeatDurationMinutes < 0) + ? (endOfWindow.AddYears( 1 ) - startDt.UtcTime).TotalMinutes + : repeatOptions.RepeatDurationMinutes + ) / repeatOptions.RepeatIntervalMinutes + ); + } + + long count = 0; + do { + // Check whether the occurrence is past the Expiration time. + // Exit early and return true so that the caller can return the result list and stop processing. + if (IsExpired( expiration, occurrence, endOfWindow )) { return true; } + + // Add the non-expired occurrence to the result list. + _ = result.Add( occurrence ); + + if (maxRepeat > 0) { + // Calculate any repeat occurrences. + occurrence = CalculateRepeatOccurrences( occurrence, repeatOptions! ); + } + count++; + } while (count <= maxRepeat); + + return false; + } + + /// + /// Calculates the next occurrence of a repeated schedule by adding the repeat interval. + /// + internal static DateTime CalculateRepeatOccurrences( + DateTime occurrence, + ScheduleRepeatOptions repeatOptions + ) => occurrence.AddMinutes( repeatOptions.RepeatIntervalMinutes ); + + internal static TimeSpan GetWindowTimeSpan( DateTime startTime, DateTime endOfWindow, ExpirationDateTimeInfo? expiration ) { + if (startTime.Kind != DateTimeKind.Utc) { + throw new InvalidOperationException( "The startTime DateTime must be in UTC." ); + } else if (endOfWindow.Kind != DateTimeKind.Utc) { + throw new InvalidOperationException( "The endOfWindow DateTime must be in UTC." ); + } + + DateTime endOfPeriod = expiration != null && expiration.UtcTime < endOfWindow + ? expiration.UtcTime + : endOfWindow; + return endOfPeriod - startTime; + } + + internal static bool IsExpired( ExpirationDateTimeInfo? expiration, DateTime occurrence, DateTime endOfWindow ) => + (expiration != null && occurrence >= expiration?.UtcTime) || occurrence >= endOfWindow; + + #endregion Repeat, Expiration, and Flow Control + + + #region Daily Occurrences + + /// + /// Calculates the daily recurrences until the end of the window or the expiration has passed. + /// Each instance is calculated by adding the DayInterval to the occurrence date. + /// + internal static void CalculateDailyOccurrences( + StartDateTimeInfo startDt, + ScheduleRepeatOptions? repeatOptions, + ExpirationDateTimeInfo? expiration, + DateTime endOfWindow, + HashSet result, + DailyRecurrence dailyRecurrence + ) { + if (dailyRecurrence.DayInterval <= 0) { return; } + DateTime translatedTime = startDt.TzTime; + TimeSpan windowPeriod = GetWindowTimeSpan( startDt.UtcTime, endOfWindow, expiration ); + + for (int i = 0; i < windowPeriod.Days; i++) { + translatedTime = translatedTime.AddDays( dailyRecurrence.DayInterval ); + + // Add the occurrence to the result list and calculate any repeat occurrences. + // Exit early if the occurrence is past the Expiration time. + if (AddToResultAndCalculateRepeatOccurrences( + startDt.ConvertToUtc( translatedTime ), + startDt, + repeatOptions, + expiration, + endOfWindow, + result + )) { return; } + } + } + + #endregion Daily Occurrences + + + #region Weekly Occurrences + + /// + /// Calculates the weekly recurrences until the end of the window or the expiration has passed. + /// First calculates the remaining occurrences in the first scheduled week, then the remaining window. + /// + internal static void CalculateWeeklyOccurrences( + StartDateTimeInfo startDt, + ScheduleRepeatOptions? repeatOptions, + ExpirationDateTimeInfo? expiration, + DateTime endOfWindow, + HashSet result, + WeeklyRecurrence weeklyRecurrence + ) { + if (weeklyRecurrence.WeekInterval <= 0) { return; } + TimeSpan windowPeriod = GetWindowTimeSpan( startDt.UtcTime, endOfWindow, expiration ); + List recurrenceDays = weeklyRecurrence.DaysOfWeek.GetDaysOfWeek( ); + LoopingList loopingWeek = [.. CalendarEnumExtensions.GetWeekOfDays( )]; + + DateTime translatedTime = startDt.TzTime; + + int firstWeekEnds = CalculateWeeklyOccurrences_GetDayDifference( loopingWeek, translatedTime.DayOfWeek, loopingWeek[0] ); + + int weekNum = 0; + int weekDayCount = 0; + for (int i = 0; i < windowPeriod.Days; i++) { + translatedTime = translatedTime.AddDays( 1 ); + DateTime utcTime = startDt.ConvertToUtc( translatedTime ); + + if (IsExpired( expiration, utcTime, endOfWindow )) { return; } + + if (recurrenceDays.Contains( translatedTime.DayOfWeek ) && (weekNum % weeklyRecurrence.WeekInterval == 0)) { + if (AddToResultAndCalculateRepeatOccurrences( + utcTime, + startDt, + repeatOptions, + expiration, + endOfWindow, + result + )) { return; } + } + + if ((weekDayCount == 0 && i == firstWeekEnds) || (weekDayCount > 0 && weekDayCount % 7 == 0)) { + weekDayCount++; + weekNum++; + } else if (weekDayCount > 0) { + weekDayCount++; + } + } + } + + /// + /// Calculates the number of days between the and the + /// by enumerating the list. + /// + internal static int CalculateWeeklyOccurrences_GetDayDifference( + LoopingList loopingWeek, + DayOfWeek targetDay, + DayOfWeek startDay + ) { + int count = 0; + if (targetDay == startDay) { return count; } + loopingWeek.CurrentIndex = loopingWeek.IndexOf( startDay ); + foreach (DayOfWeek dayOfWeek in loopingWeek) { + count++; + if (dayOfWeek == targetDay) { break; } + } + return count; + } + + #endregion Weekly Occurrences + + + #region Monthly Occurrences + + /// + /// Calculates the monthly recurrences until the end of the window or the expiration has passed. + /// Monthly recurrence schedules can be calculated using one of two ways, depending on which fields are set: + /// + /// DayNumbers mode: MonthsOfYear + DayNumbers + /// WeekAndDay mode: MonthsOfYear + (WeekNumber + DaysOfWeek) + /// + /// + internal static void CalculateMonthlyOccurrences( + DateTime occurrence, + StartDateTimeInfo startDt, + ScheduleRepeatOptions? repeatOptions, + ExpirationDateTimeInfo? expiration, + DateTime endOfWindow, + HashSet result, + MonthlyRecurrence monthlyRecurrence, + TimeOnly startTime + ) { + int[] recurrenceMonths = monthlyRecurrence.MonthsOfYear.GetIntMonths( ); + if (monthlyRecurrence.DayNumbers != null) { + CalculateMonthlyOccurrences_DayNumbersWithinMonth( + occurrence, + startDt, + repeatOptions, + expiration, + endOfWindow, + result, + monthlyRecurrence, + recurrenceMonths + ); + } else { + CalculateMonthlyOccurrences_WeekAndDay( + occurrence, + startDt, + repeatOptions, + expiration, + endOfWindow, + result, + monthlyRecurrence, + startTime, + recurrenceMonths + ); + } + } + + #region DayNumberWithinMonth + + /// + /// Calculates the DayNumbers monthly recurrences until the end of the window or the expiration has passed. + /// The formula is essentially (MonthsOfYear + DayNumbers). + /// + internal static void CalculateMonthlyOccurrences_DayNumbersWithinMonth( + DateTime occurrence, + StartDateTimeInfo startDt, + ScheduleRepeatOptions? repeatOptions, + ExpirationDateTimeInfo? expiration, + DateTime endOfWindow, + HashSet result, + MonthlyRecurrence monthlyRecurrence, + int[] recurrenceMonths + ) { + int occurrenceYear = occurrence.Year; + int occurrenceMonth = occurrence.Month; + int[] dayNumbers = monthlyRecurrence.DayNumbers!; + Array.Sort( dayNumbers, CompareNumbers ); + + if (CalculateMonthlyOccurrences_DayNumbersWithinMonth_EndOfFirstMonth( + startDt, + repeatOptions, + expiration, + endOfWindow, + result, + recurrenceMonths, + dayNumbers, + occurrence.Day, + occurrenceYear, + occurrenceMonth + )) { return; } + + int[] remainingRecurrenceMonths = recurrenceMonths.GetRemainingMonthsInYear( occurrenceMonth, true ); + if (remainingRecurrenceMonths.Length == 0) { + remainingRecurrenceMonths = recurrenceMonths; + occurrenceYear++; + } + + CalculateMonthlyOccurrences_DayNumbersWithinMonth_RemainingWindow( + occurrence, + startDt, + repeatOptions, + expiration, + endOfWindow, + result, + recurrenceMonths, + remainingRecurrenceMonths, + dayNumbers, + occurrenceYear + ); + } + + /// + /// Calculates the remaining DayNumbers monthly recurrences in the first scheduled month. + /// + /// Returns true if occurrence has reached its expiration or the endOfWindow. + internal static bool CalculateMonthlyOccurrences_DayNumbersWithinMonth_EndOfFirstMonth( + StartDateTimeInfo startDt, + ScheduleRepeatOptions? repeatOptions, + ExpirationDateTimeInfo? expiration, + DateTime endOfWindow, + HashSet result, + int[] recurrenceMonths, + int[] dayNumbers, + int occurrenceDay, + int occurrenceYear, + int occurrenceMonth + ) { + if (recurrenceMonths.Contains( occurrenceMonth )) { + // Finish out the first month + foreach (int day in CalculateMonthlyOccurrencesDayNumbersWithinMonth( occurrenceYear, occurrenceMonth, dayNumbers )) { + if (day <= occurrenceDay) { continue; } + DateTime occurrence = startDt.ConvertToUtc( + new DateTime( + year: occurrenceYear, + month: occurrenceMonth, + day: day, + hour: startDt.TzTime.Hour, + minute: startDt.TzTime.Minute, + second: startDt.TzTime.Second, + kind: DateTimeKind.Unspecified + ) + ); + // Add the occurrence to the result list and calculate any repeat occurrences. + // Exit early if the occurrence is past the Expiration time. + if (AddToResultAndCalculateRepeatOccurrences( + occurrence, + startDt, + repeatOptions, + expiration, + endOfWindow, + result + )) { return true; } + } + } + return false; + } + + /// + /// Calculates the monthly recurrences from after the first complete month until the end of the window or expiration. + /// + internal static void CalculateMonthlyOccurrences_DayNumbersWithinMonth_RemainingWindow( + DateTime occurrence, + StartDateTimeInfo startDt, + ScheduleRepeatOptions? repeatOptions, + ExpirationDateTimeInfo? expiration, + DateTime endOfWindow, + HashSet result, + int[] recurrenceMonths, + int[] remainingRecurrenceMonths, + int[] dayNumbers, + int occurrenceYear + ) { + do { + foreach (int month in remainingRecurrenceMonths) { + foreach (int day in CalculateMonthlyOccurrencesDayNumbersWithinMonth( occurrenceYear, month, dayNumbers )) { + occurrence = startDt.ConvertToUtc( + new DateTime( + year: occurrenceYear, + month: month, + day: day, + hour: startDt.TzTime.Hour, + minute: startDt.TzTime.Minute, + second: startDt.TzTime.Second, + kind: DateTimeKind.Unspecified + ) + ); + // Add the occurrence to the result list and calculate any repeat occurrences. + // Exit early if the occurrence is past the Expiration time. + if (AddToResultAndCalculateRepeatOccurrences( + occurrence, + startDt, + repeatOptions, + expiration, + endOfWindow, + result + )) { return; } + } + } + occurrenceYear++; + remainingRecurrenceMonths = recurrenceMonths; + } while (occurrence < endOfWindow); + } + + internal static int[] CalculateMonthlyOccurrencesDayNumbersWithinMonth( + int year, + int month, + int[] dayNums + ) { + List result = []; + foreach (int dayNum in dayNums) { + result.Add( CalculateMonthlyOccurrences_DayNumbersWithinMonth( year, month, dayNum ) ); + } + return [.. result + .Distinct( ) + .Where( i => i > 0 ) + .Order( )]; + } + + /// + /// Converts negative day numbers into their positive counterparts. + /// + /// The positive integer corresponding to the negative day of month, or 0 if the date does not correspond to a day within the month. + internal static int CalculateMonthlyOccurrences_DayNumbersWithinMonth( + int year, + int month, + int day + ) { + int daysInMonth = DateTime.DaysInMonth( year, month ); + return day >= 0 + ? day <= daysInMonth + ? day + : 0 + : (day * -1) <= daysInMonth + ? daysInMonth + day + 1 + : 0; + } + + internal static int CompareNumbers( int x, int y ) => + x > 0 && y > 0 // Both numbers are positive + ? x.CompareTo( y ) + : x > 0 && y < 0 // x is positive, y is negative + ? -1 + : x < 0 && y > 0 // x is negative, y is positive + ? 1 + : -x.CompareTo( -y ); // Both numbers are negative + + #endregion DayNumberWithinMonth + + + #region WeekAndDay + + /// + /// Calculates the WeekAndDay monthly recurrences until the end of the window or the expiration has passed. + /// The formula is essentially (MonthsOfYear + (WeekNumber + DaysOfWeek)). + /// + internal static void CalculateMonthlyOccurrences_WeekAndDay( + DateTime occurrence, + StartDateTimeInfo startDt, + ScheduleRepeatOptions? repeatOptions, + ExpirationDateTimeInfo? expiration, + DateTime endOfWindow, + HashSet result, + MonthlyRecurrence monthlyRecurrence, + TimeOnly startTime, + int[] recurrenceMonths + ) { + int occurrenceYear = occurrence.Year; + int occurrenceMonth = occurrence.Month; + List daysOfWeek = ( (DaysOfWeek) monthlyRecurrence.DaysOfWeek! ).GetDaysOfWeek( ).OrderDaysOfWeek( ); + List weekOfDays = CalendarEnumExtensions.GetWeekOfDays( ); + int[] weekNumbers = monthlyRecurrence.WeekNumber!.Value.GetWeekNumbersInMonth( ); + + if (CalculateMonthlyOccurrences_WeekAndDay_EndOfFirstMonth( + occurrence, + startDt, + repeatOptions, + expiration, + endOfWindow, + result, + startTime, + recurrenceMonths, + daysOfWeek, + weekOfDays, + weekNumbers, + occurrenceYear, + occurrenceMonth + )) { return; } + + int[] remainingRecurrenceMonths = recurrenceMonths.GetRemainingMonthsInYear( occurrenceMonth, true ); + if (remainingRecurrenceMonths.Length == 0) { + remainingRecurrenceMonths = recurrenceMonths; + occurrenceYear++; + } + + CalculateMonthlyOccurrences_WeekAndDay_RemainingWindow( + occurrence, + startDt, + repeatOptions, + expiration, + endOfWindow, + result, + startTime, + remainingRecurrenceMonths, + recurrenceMonths, + occurrenceYear, + daysOfWeek, + weekOfDays, + weekNumbers + ); + } + + /// + /// Finishes out the first month for WeekAndDay recurrence. + /// + /// Returns true if occurrence has reached its expiration or the endOfWindow. + internal static bool CalculateMonthlyOccurrences_WeekAndDay_EndOfFirstMonth( + DateTime occurrence, + StartDateTimeInfo startDt, + ScheduleRepeatOptions? repeatOptions, + ExpirationDateTimeInfo? expiration, + DateTime endOfWindow, + HashSet result, + TimeOnly startTime, + int[] recurrenceMonths, + List daysOfWeek, + List weekOfDays, + int[] weekNumbers, + int occurrenceYear, + int occurrenceMonth + ) { + if (recurrenceMonths.Contains( occurrenceMonth )) { + int daysInMonth = DateTime.DaysInMonth( occurrenceYear, occurrenceMonth ); + DateTime occurrenceMonthStart = new( occurrenceYear, occurrenceMonth, 1 ); + List daysInFirstWeek = occurrenceMonthStart.DayOfWeek.GetDaysInWeekFromStartDay( ); + List daysInLastWeek = CalculateMonthlyOccurrences_WeekAndDay_GetDaysInLastWeekOfMonth( weekOfDays, daysInMonth, daysInFirstWeek.Count ); + List remainingDaysOfWeek = daysOfWeek.GetRemainingDaysInWeek( occurrenceMonthStart.DayOfWeek, true ); + + int weekCount = CalculateMonthlyOccurrences_WeekAndDay_GetWeekCount( daysInMonth, daysInFirstWeek.Count, daysInLastWeek.Count ); + + int currentWeek = Convert.ToInt32( Math.Floor( (decimal) occurrence.Day / 7 ) ); + int[] remainingWeekNumbers = [.. weekNumbers.Where( weekNum => ( weekNum >= currentWeek ) && ( weekNum <= weekCount ) )]; + + if (CalculateMonthlyOccurrences_WeekAndDay_RemainingMonth( + occurrence, + startDt, + repeatOptions, + expiration, + endOfWindow, + result, + startTime, + occurrenceYear, + occurrenceMonth, + weekNumbers, + weekCount, + daysOfWeek, + remainingDaysOfWeek, + daysInFirstWeek, + daysInLastWeek + )) { return true; } + } + return false; + } + + /// + /// Calculates the monthly recurrences from after the first complete month until the end of the window or expiration. + /// + internal static void CalculateMonthlyOccurrences_WeekAndDay_RemainingWindow( + DateTime occurrence, + StartDateTimeInfo startDt, + ScheduleRepeatOptions? repeatOptions, + ExpirationDateTimeInfo? expiration, + DateTime endOfWindow, + HashSet result, + TimeOnly startTime, + int[] remainingRecurrenceMonths, + int[] recurrenceMonths, + int occurrenceYear, + List daysOfWeek, + List weekOfDays, + int[] weekNumbers + ) { + do { + foreach (int month in remainingRecurrenceMonths) { + int daysInMonth = DateTime.DaysInMonth( occurrenceYear, month ); + DateTime occurrenceMonthStart = new( occurrenceYear, month, 1 ); + List daysInFirstWeek = occurrenceMonthStart.DayOfWeek.GetDaysInWeekFromStartDay( ); + List daysInLastWeek = CalculateMonthlyOccurrences_WeekAndDay_GetDaysInLastWeekOfMonth( weekOfDays, daysInMonth, daysInFirstWeek.Count ); + int weekCount = CalculateMonthlyOccurrences_WeekAndDay_GetWeekCount( daysInMonth, daysInFirstWeek.Count, daysInLastWeek.Count ); + if (CalculateMonthlyOccurrences_WeekAndDay_RemainingMonth( + occurrence, + startDt, + repeatOptions, + expiration, + endOfWindow, + result, + startTime, + occurrenceYear, + month, + weekNumbers, + weekCount, + daysOfWeek, + daysOfWeek, + daysInFirstWeek, + daysInLastWeek + )) { return; } + } + occurrenceYear++; + remainingRecurrenceMonths = recurrenceMonths; + } while (occurrence < endOfWindow); + } + + internal static List CalculateMonthlyOccurrences_WeekAndDay_GetDaysInLastWeekOfMonth( + List weekOfDays, + int daysInMonth, + int daysInFirstWeek + ) { + int numDaysInLastWeek = ( daysInMonth - daysInFirstWeek ) % 7; + List daysInLastWeek = []; + int count = 0; + foreach (DayOfWeek dayOfWeek in weekOfDays) { + count++; + daysInLastWeek.Add( dayOfWeek ); + if (count == numDaysInLastWeek) { break; } + } + return daysInLastWeek; + } + + internal static int CalculateMonthlyOccurrences_WeekAndDay_GetWeekCount( + int daysInMonth, + int daysInFirstWeek, + int daysInLastWeek + ) => ((daysInMonth - daysInFirstWeek - daysInLastWeek) / 7) + 2; + + /// + /// The primary Monthly Recurrence WeekAndDay logic. + /// + /// Returns true if occurrence has reached its expiration or the endOfWindow. + internal static bool CalculateMonthlyOccurrences_WeekAndDay_RemainingMonth( + DateTime occurrence, + StartDateTimeInfo startDt, + ScheduleRepeatOptions? repeatOptions, + ExpirationDateTimeInfo? expiration, + DateTime endOfWindow, + HashSet result, + TimeOnly startTime, + int occurrenceYear, + int occurrenceMonth, + int[] weekNumbers, + int weekCount, + List daysOfWeek, + List remainingDaysOfWeek, + List daysInFirstWeek, + List daysInLastWeek + ) { + foreach (int week in weekNumbers.Where( week => week <= weekCount )) { + foreach (DayOfWeek day in remainingDaysOfWeek) { + int dayNum = 0; + if (week == 1) { + if (daysInFirstWeek.Contains( day )) { + dayNum = daysInFirstWeek.IndexOf( day ) + 1; + } else { + continue; + } + } else if (week == weekCount) { + if (daysInLastWeek.Contains( day )) { + dayNum = ((week - 1) * 7) + daysInLastWeek.IndexOf( day ) + 1; + } else { + continue; + } + } else { + dayNum = ((week - 1) * 7) + daysOfWeek.IndexOf( day ) + 1; + } + + // Guard against invalid day numbers (formula can exceed days-in-month for partial first/last weeks). + int maxDay = DateTime.DaysInMonth( occurrenceYear, occurrenceMonth ); + if (dayNum < 1 || dayNum > maxDay) { continue; } + + occurrence = new DateOnly( occurrenceYear, occurrenceMonth, dayNum ).ToDateTime( startTime, DateTimeKind.Utc ); + + // Add the occurrence to the result list and calculate any repeat occurrences. + // Exit early if the occurrence is past the Expiration time. + if (AddToResultAndCalculateRepeatOccurrences( + occurrence, + startDt, + repeatOptions, + expiration, + endOfWindow, + result + )) { return true; } + } + remainingDaysOfWeek = daysOfWeek; + } + + return false; + } + + #endregion WeekAndDay + + #endregion Monthly Occurrences + + #endregion Calculate Occurrences +} diff --git a/src/Werkr.Core/Scheduling/ScheduleDescriptionBuilder.cs b/src/Werkr.Core/Scheduling/ScheduleDescriptionBuilder.cs new file mode 100644 index 0000000..913dcec --- /dev/null +++ b/src/Werkr.Core/Scheduling/ScheduleDescriptionBuilder.cs @@ -0,0 +1,180 @@ +using System.Text; + +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Extensions; +using Werkr.Data.Calendar.Models; +using Werkr.Data.Entities.Schedule; +using Werkr.Data.Ranges; + +namespace Werkr.Core.Scheduling; + +/// +/// Produces human-readable descriptions of a composite. +/// +/// Example outputs: +/// "Once on 2026-03-15 at 9:00 AM EST" +/// "Every 2 days at 9:00 AM EST" +/// "Weekly on Mon, Wed, Fri at 9:00 AM EST" +/// "Monthly on the 1st and 15th in Jan–Mar, Jun at 9:00 AM EST" +/// +/// +public static class ScheduleDescriptionBuilder { + /// + /// Returns a friendly description string for the given schedule. + /// + public static string GetFriendlyDescription( Schedule schedule ) { + StringBuilder sb = new( ); + + // Recurrence pattern + if (schedule.DailyRecurrence is not null) { + AppendDailyDescription( sb, schedule.DailyRecurrence ); + } else if (schedule.WeeklyRecurrence is not null) { + AppendWeeklyDescription( sb, schedule.WeeklyRecurrence ); + } else if (schedule.MonthlyRecurrence is not null) { + AppendMonthlyDescription( sb, schedule.MonthlyRecurrence ); + } else { + _ = sb.Append( "Once" ); + if (schedule.StartDateTime is not null) { + _ = sb.Append( $" on {schedule.StartDateTime.Date:yyyy-MM-dd}" ); + } + } + + // Time and timezone + if (schedule.StartDateTime is not null) { + string timeStr = schedule.StartDateTime.Time.ToString( "h:mm tt" ); + string tzAbbrev = GetTimeZoneAbbreviation( schedule.StartDateTime.TimeZone ); + _ = sb.Append( $" at {timeStr} {tzAbbrev}" ); + } + + // Repeat options + if (schedule.RepeatOptions is not null) { + AppendRepeatDescription( sb, schedule.RepeatOptions ); + } + + // Expiration + if (schedule.Expiration is not null) { + string expTimeStr = schedule.Expiration.Time.ToString( "h:mm tt" ); + string expTzAbbrev = GetTimeZoneAbbreviation( schedule.Expiration.TimeZone ); + _ = sb.Append( $" until {schedule.Expiration.Date:yyyy-MM-dd} at {expTimeStr} {expTzAbbrev}" ); + } + + // Holiday calendar + if (schedule.HolidayCalendar is not null && schedule.HolidayCalendarMode is not null) { + string calName = schedule.HolidayCalendar.Name; + _ = schedule.HolidayCalendarMode.Value switch { + HolidayCalendarMode.Blocklist => sb.Append( $", excluding {calName}" ), + HolidayCalendarMode.Allowlist => sb.Append( $", only on {calName}" ), + _ => sb, + }; + } + + return sb.ToString( ); + } + + private static void AppendDailyDescription( StringBuilder sb, DailyRecurrence daily ) { + _ = daily.DayInterval == 1 ? sb.Append( "Daily" ) : sb.Append( $"Every {daily.DayInterval} days" ); + } + + private static void AppendWeeklyDescription( StringBuilder sb, WeeklyRecurrence weekly ) { + string days = RangeOfDays.ToString( RangeOfDays.GetContiguousRanges( weekly.DaysOfWeek ), abbreviated: true ); + + _ = weekly.WeekInterval == 1 ? sb.Append( $"Weekly on {days}" ) : sb.Append( $"Every {weekly.WeekInterval} weeks on {days}" ); + } + + private static void AppendMonthlyDescription( StringBuilder sb, MonthlyRecurrence monthly ) { + string months = RangeOfMonths.ToString( + RangeOfMonths.GetContiguousRanges( monthly.MonthsOfYear ), abbreviated: true ); + + if (monthly.DayNumbers is { Length: > 0 }) { + // Day-number mode + string dayList = FormatDayNumbers( monthly.DayNumbers ); + _ = sb.Append( $"Monthly on the {dayList} in {months}" ); + } else if (monthly.WeekNumber is not null && monthly.DaysOfWeek is not null) { + // Week+day mode + string weekNums = RangeOfWeekNums.ToString( + RangeOfWeekNums.GetContiguousRanges( monthly.WeekNumber.Value ) ); + string days = ( (DaysOfWeek) monthly.DaysOfWeek ).ToString( abbreviated: true ); + _ = sb.Append( $"Monthly on the {weekNums} {days} in {months}" ); + } else { + _ = sb.Append( $"Monthly in {months}" ); + } + } + + private static void AppendRepeatDescription( StringBuilder sb, ScheduleRepeatOptions options ) { + string interval = FormatMinutes( options.RepeatIntervalMinutes ); + + if (options.RepeatDurationMinutes < 0) { + _ = sb.Append( $" repeating every {interval} indefinitely" ); + } else { + string duration = FormatMinutes( options.RepeatDurationMinutes ); + _ = sb.Append( $" repeating every {interval} for {duration}" ); + } + } + + private static string FormatMinutes( int minutes ) { + if (minutes >= 1440 && minutes % 1440 == 0) { + int days = minutes / 1440; + return days == 1 ? "1 day" : $"{days} days"; + } + if (minutes >= 60 && minutes % 60 == 0) { + int hours = minutes / 60; + return hours == 1 ? "1 hour" : $"{hours} hours"; + } + return minutes == 1 ? "1 min" : $"{minutes} min"; + } + + private static string FormatDayNumbers( int[] dayNumbers ) { + int[] sorted = [.. dayNumbers.Order( )]; + string[] formatted = new string[sorted.Length]; + for (int i = 0; i < sorted.Length; i++) { + formatted[i] = GetOrdinal( sorted[i] ); + } + return formatted.Length switch { + 1 => formatted[0], + 2 => $"{formatted[0]} and {formatted[1]}", + _ => string.Join( ", ", formatted[..^1] ) + $", and {formatted[^1]}", + }; + } + + private static string GetOrdinal( int number ) { + if (number < 0) { + return $"{number}th-from-end"; + } + + int abs = Math.Abs( number ); + string suffix = ( abs % 100 ) switch { + 11 or 12 or 13 => "th", + _ => ( abs % 10 ) switch { + 1 => "st", + 2 => "nd", + 3 => "rd", + _ => "th", + } + }; + return $"{number}{suffix}"; + } + + private static string GetTimeZoneAbbreviation( TimeZoneInfo tz ) { + // Use the standard abbreviation (e.g., "EST", "PST", "UTC") + // TimeZoneInfo doesn't expose abbreviations directly, so derive from StandardName + if (tz == TimeZoneInfo.Utc) { + return "UTC"; + } + + string standardName = tz.StandardName; + // If the standard name is a short abbreviation already, use it + if (standardName.Length <= 5) { + return standardName; + } + + // Otherwise, build an abbreviation from the capital letters + StringBuilder abbrev = new( ); + foreach (char c in standardName) { + if (char.IsUpper( c )) { + _ = abbrev.Append( c ); + } + } + string result = abbrev.ToString( ); + return result.Length >= 2 ? result : standardName; + } +} diff --git a/src/Werkr.Core/Scheduling/ScheduleOccurrenceResult.cs b/src/Werkr.Core/Scheduling/ScheduleOccurrenceResult.cs new file mode 100644 index 0000000..b72b545 --- /dev/null +++ b/src/Werkr.Core/Scheduling/ScheduleOccurrenceResult.cs @@ -0,0 +1,10 @@ +namespace Werkr.Core.Scheduling; + +/// +/// Result of schedule occurrence calculation, including both kept and suppressed occurrences. +/// +/// The occurrences that passed the holiday filter (or all occurrences if no filter). +/// Occurrences that were filtered out by the holiday calendar. +public sealed record ScheduleOccurrenceResult( + IReadOnlyList Occurrences, + IReadOnlyList Suppressed ); diff --git a/src/Werkr.Core/Scheduling/ScheduleService.cs b/src/Werkr.Core/Scheduling/ScheduleService.cs new file mode 100644 index 0000000..8bfc1de --- /dev/null +++ b/src/Werkr.Core/Scheduling/ScheduleService.cs @@ -0,0 +1,324 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Werkr.Data; +using Werkr.Data.Calendar.Models; +using Werkr.Data.Calendar.Validation; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Core.Scheduling; + +/// +/// Provides CRUD operations for composite models, +/// mediating between the API layer and the underlying . +/// +public sealed partial class ScheduleService( + WerkrDbContext dbContext, + HolidayDateService holidayDateService, + ILogger logger +) { + private readonly WerkrDbContext _db = dbContext; + private readonly HolidayDateService _holidayDateService = holidayDateService; + private readonly ILogger _logger = logger; + + /// + /// Creates a new schedule with all sub-entities in a single save. + /// + /// Thrown when the schedule fails validation. + public async Task CreateAsync( Schedule schedule, CancellationToken ct = default ) { + ValidationResult? validation = ScheduleValidator.Validate( schedule ); + if (validation != ValidationResult.Success) { + throw new ValidationException( validation!.ErrorMessage ); + } + + DbSchedule dbSchedule = new( ) { + Name = schedule.DbSchedule.Name, + StopTaskAfterMinutes = schedule.DbSchedule.StopTaskAfterMinutes, + }; + _ = _db.Schedules.Add( dbSchedule ); + // SaveChanges to generate the Guid Id + _ = await _db.SaveChangesAsync( ct ); + Guid id = dbSchedule.Id; + + // StartDateTime (required) + StartDateTimeInfo startDt = schedule.StartDateTime!; + startDt.ScheduleId = id; + _ = _db.StartDateTimeInfos.Add( startDt ); + + // Optional sub-entities + if (schedule.Expiration is not null) { + schedule.Expiration.ScheduleId = id; + _ = _db.ExpirationDateTimeInfos.Add( schedule.Expiration ); + } + if (schedule.RepeatOptions is not null) { + schedule.RepeatOptions.ScheduleId = id; + _ = _db.ScheduleRepeatOptions.Add( schedule.RepeatOptions ); + } + if (schedule.DailyRecurrence is not null) { + schedule.DailyRecurrence.ScheduleId = id; + _ = _db.DailyRecurrences.Add( schedule.DailyRecurrence ); + } + if (schedule.WeeklyRecurrence is not null) { + schedule.WeeklyRecurrence.ScheduleId = id; + _ = _db.WeeklyRecurrences.Add( schedule.WeeklyRecurrence ); + } + if (schedule.MonthlyRecurrence is not null) { + schedule.MonthlyRecurrence.ScheduleId = id; + _ = _db.MonthlyRecurrences.Add( schedule.MonthlyRecurrence ); + } + + _ = await _db.SaveChangesAsync( ct ); + LogScheduleCreated( _logger, id, dbSchedule.Name ); + + return (await GetByIdAsync( id, ct ))!; + } + + /// + /// Updates an existing schedule and its sub-entities. + /// Handles changing recurrence type by removing old and adding new. + /// + /// Thrown when the schedule fails validation. + /// Thrown when the schedule does not exist. + public async Task UpdateAsync( Schedule schedule, CancellationToken ct = default ) { + ValidationResult? validation = ScheduleValidator.Validate( schedule ); + if (validation != ValidationResult.Success) { + throw new ValidationException( validation!.ErrorMessage ); + } + + Guid id = schedule.DbSchedule.Id; + DbSchedule existing = await _db.Schedules.FindAsync( [id], ct ) + ?? throw new KeyNotFoundException( $"Schedule {id} not found." ); + + // Update core properties + existing.Name = schedule.DbSchedule.Name; + existing.StopTaskAfterMinutes = schedule.DbSchedule.StopTaskAfterMinutes; + + // Update StartDateTime + StartDateTimeInfo? startDt = await _db.StartDateTimeInfos.FindAsync( [id], ct ); + if (startDt is null) { + schedule.StartDateTime!.ScheduleId = id; + _ = _db.StartDateTimeInfos.Add( schedule.StartDateTime ); + } else { + startDt.Date = schedule.StartDateTime!.Date; + startDt.Time = schedule.StartDateTime.Time; + startDt.TimeZone = schedule.StartDateTime.TimeZone; + } + + // Update Expiration + ExpirationDateTimeInfo? expiration = await _db.ExpirationDateTimeInfos.FindAsync( [id], ct ); + if (schedule.Expiration is not null) { + if (expiration is null) { + schedule.Expiration.ScheduleId = id; + _ = _db.ExpirationDateTimeInfos.Add( schedule.Expiration ); + } else { + expiration.Date = schedule.Expiration.Date; + expiration.Time = schedule.Expiration.Time; + expiration.TimeZone = schedule.Expiration.TimeZone; + } + } else if (expiration is not null) { + _ = _db.ExpirationDateTimeInfos.Remove( expiration ); + } + + // Update RepeatOptions + ScheduleRepeatOptions? repeat = await _db.ScheduleRepeatOptions.FindAsync( [id], ct ); + if (schedule.RepeatOptions is not null) { + if (repeat is null) { + schedule.RepeatOptions.ScheduleId = id; + _ = _db.ScheduleRepeatOptions.Add( schedule.RepeatOptions ); + } else { + repeat.RepeatIntervalMinutes = schedule.RepeatOptions.RepeatIntervalMinutes; + repeat.RepeatDurationMinutes = schedule.RepeatOptions.RepeatDurationMinutes; + } + } else if (repeat is not null) { + _ = _db.ScheduleRepeatOptions.Remove( repeat ); + } + + // Recurrence — remove old, add new (at most one type) + DailyRecurrence? daily = await _db.DailyRecurrences.FindAsync( [id], ct ); + WeeklyRecurrence? weekly = await _db.WeeklyRecurrences.FindAsync( [id], ct ); + MonthlyRecurrence? monthly = await _db.MonthlyRecurrences.FindAsync( [id], ct ); + + // Remove existing recurrences that differ from the incoming type + if (daily is not null && schedule.DailyRecurrence is null) { + _ = _db.DailyRecurrences.Remove( daily ); + } + + if (weekly is not null && schedule.WeeklyRecurrence is null) { + _ = _db.WeeklyRecurrences.Remove( weekly ); + } + + if (monthly is not null && schedule.MonthlyRecurrence is null) { + _ = _db.MonthlyRecurrences.Remove( monthly ); + } + + // Add or update the incoming recurrence + if (schedule.DailyRecurrence is not null) { + if (daily is null) { + schedule.DailyRecurrence.ScheduleId = id; + _ = _db.DailyRecurrences.Add( schedule.DailyRecurrence ); + } else { + daily.DayInterval = schedule.DailyRecurrence.DayInterval; + } + } + if (schedule.WeeklyRecurrence is not null) { + if (weekly is null) { + schedule.WeeklyRecurrence.ScheduleId = id; + _ = _db.WeeklyRecurrences.Add( schedule.WeeklyRecurrence ); + } else { + weekly.WeekInterval = schedule.WeeklyRecurrence.WeekInterval; + weekly.DaysOfWeek = schedule.WeeklyRecurrence.DaysOfWeek; + } + } + if (schedule.MonthlyRecurrence is not null) { + if (monthly is null) { + schedule.MonthlyRecurrence.ScheduleId = id; + _ = _db.MonthlyRecurrences.Add( schedule.MonthlyRecurrence ); + } else { + monthly.MonthsOfYear = schedule.MonthlyRecurrence.MonthsOfYear; + monthly.DayNumbers = schedule.MonthlyRecurrence.DayNumbers; + monthly.WeekNumber = schedule.MonthlyRecurrence.WeekNumber; + monthly.DaysOfWeek = schedule.MonthlyRecurrence.DaysOfWeek; + } + } + + _ = await _db.SaveChangesAsync( ct ); + LogScheduleUpdated( _logger, id ); + + return (await GetByIdAsync( id, ct ))!; + } + + /// + /// Deletes a schedule and all related sub-entities. + /// + /// Thrown when the schedule does not exist. + public async Task DeleteAsync( Guid scheduleId, CancellationToken ct = default ) { + DbSchedule existing = await _db.Schedules.FindAsync( [scheduleId], ct ) + ?? throw new KeyNotFoundException( $"Schedule {scheduleId} not found." ); + + // Remove all sub-entities + StartDateTimeInfo? startDt = await _db.StartDateTimeInfos.FindAsync( [scheduleId], ct ); + if (startDt is not null) { + _ = _db.StartDateTimeInfos.Remove( startDt ); + } + + ExpirationDateTimeInfo? expiration = await _db.ExpirationDateTimeInfos.FindAsync( [scheduleId], ct ); + if (expiration is not null) { + _ = _db.ExpirationDateTimeInfos.Remove( expiration ); + } + + ScheduleRepeatOptions? repeat = await _db.ScheduleRepeatOptions.FindAsync( [scheduleId], ct ); + if (repeat is not null) { + _ = _db.ScheduleRepeatOptions.Remove( repeat ); + } + + DailyRecurrence? daily = await _db.DailyRecurrences.FindAsync( [scheduleId], ct ); + if (daily is not null) { + _ = _db.DailyRecurrences.Remove( daily ); + } + + WeeklyRecurrence? weekly = await _db.WeeklyRecurrences.FindAsync( [scheduleId], ct ); + if (weekly is not null) { + _ = _db.WeeklyRecurrences.Remove( weekly ); + } + + MonthlyRecurrence? monthly = await _db.MonthlyRecurrences.FindAsync( [scheduleId], ct ); + if (monthly is not null) { + _ = _db.MonthlyRecurrences.Remove( monthly ); + } + + _ = _db.Schedules.Remove( existing ); + _ = await _db.SaveChangesAsync( ct ); + + LogScheduleDeleted( _logger, scheduleId ); + } + + /// + /// Loads a complete composite by ID, or returns null if not found. + /// + public async Task GetByIdAsync( Guid scheduleId, CancellationToken ct = default ) { + DbSchedule? dbSchedule = await _db.Schedules.FindAsync( [scheduleId], ct ); + return dbSchedule is null ? null : await BuildComposite( dbSchedule, ct ); + } + + /// + /// Loads a schedule by name, or returns null if not found. + /// + public async Task GetByNameAsync( string name, CancellationToken ct = default ) { + DbSchedule? dbSchedule = await _db.Schedules + .FirstOrDefaultAsync( s => s.Name == name, ct ); + return dbSchedule is null ? null : await BuildComposite( dbSchedule, ct ); + } + + /// + /// Loads all schedules as composite models. + /// + public async Task> GetAllAsync( CancellationToken ct = default ) { + List dbSchedules = await _db.Schedules.ToListAsync( ct ); + List result = new( dbSchedules.Count ); + foreach (DbSchedule dbSchedule in dbSchedules) { + result.Add( await BuildComposite( dbSchedule, ct ) ); + } + return result; + } + + /// + /// Loads the schedule by ID and calculates its occurrences within the given window, + /// applying any attached holiday calendar filter. + /// + /// Thrown when the schedule does not exist. + public async Task PreviewOccurrencesAsync( + Guid scheduleId, DateTime windowEnd, CancellationToken ct = default + ) { + Schedule schedule = await GetByIdAsync( scheduleId, ct ) + ?? throw new KeyNotFoundException( $"Schedule {scheduleId} not found." ); + + IReadOnlyList? holidayDates = null; + if (schedule.HolidayCalendar is not null && schedule.StartDateTime is not null) { + DateTime start = schedule.StartDateTime.UtcTime; + holidayDates = await _holidayDateService.GetDatesForRangeAsync( + schedule.HolidayCalendar.Id, + DateOnly.FromDateTime( start ), + DateOnly.FromDateTime( windowEnd ), + ct ); + } + + return ScheduleCalculator.CalculateOccurrences( + schedule, windowEnd, holidayDates, schedule.HolidayCalendarMode ); + } + + /// + /// Assembles a composite from a and its sub-entities. + /// Follows the reference code's pattern of loading each sub-entity by FK. + /// + private async Task BuildComposite( DbSchedule dbSchedule, CancellationToken ct ) { + Guid id = dbSchedule.Id; + Schedule schedule = new( ) { + DbSchedule = dbSchedule, + StartDateTime = await _db.StartDateTimeInfos.FindAsync( [id], ct ), + Expiration = await _db.ExpirationDateTimeInfos.FindAsync( [id], ct ), + RepeatOptions = await _db.ScheduleRepeatOptions.FindAsync( [id], ct ), + DailyRecurrence = await _db.DailyRecurrences.FindAsync( [id], ct ), + WeeklyRecurrence = await _db.WeeklyRecurrences.FindAsync( [id], ct ), + MonthlyRecurrence = await _db.MonthlyRecurrences.FindAsync( [id], ct ), + }; + + // Load holiday calendar link if attached + ScheduleHolidayCalendar? link = await _db.ScheduleHolidayCalendars + .FirstOrDefaultAsync( shc => shc.ScheduleId == id, ct ); + if (link is not null) { + schedule.HolidayCalendarMode = link.Mode; + schedule.HolidayCalendar = await _db.HolidayCalendars.FindAsync( [link.HolidayCalendarId], ct ); + } + + return schedule; + } + + [LoggerMessage( Level = LogLevel.Information, Message = "Created schedule {ScheduleId} ({Name})" )] + private static partial void LogScheduleCreated( ILogger logger, Guid scheduleId, string name ); + + [LoggerMessage( Level = LogLevel.Information, Message = "Updated schedule {ScheduleId}" )] + private static partial void LogScheduleUpdated( ILogger logger, Guid scheduleId ); + + [LoggerMessage( Level = LogLevel.Information, Message = "Deleted schedule {ScheduleId}" )] + private static partial void LogScheduleDeleted( ILogger logger, Guid scheduleId ); +} diff --git a/src/Werkr.Core/Scheduling/SuppressedOccurrence.cs b/src/Werkr.Core/Scheduling/SuppressedOccurrence.cs new file mode 100644 index 0000000..caf3a92 --- /dev/null +++ b/src/Werkr.Core/Scheduling/SuppressedOccurrence.cs @@ -0,0 +1,12 @@ +namespace Werkr.Core.Scheduling; + +/// +/// An occurrence that was suppressed (or, in allowlist mode, not matched) by a holiday calendar filter. +/// +/// The UTC time of the suppressed occurrence. +/// The name of the holiday that caused the suppression, or empty for allowlist misses. +/// A human-readable reason for the suppression. +public sealed record SuppressedOccurrence( + DateTime UtcTime, + string HolidayName, + string Reason ); diff --git a/src/Werkr.Core/Security/IFilePathResolver.cs b/src/Werkr.Core/Security/IFilePathResolver.cs new file mode 100644 index 0000000..95a9a16 --- /dev/null +++ b/src/Werkr.Core/Security/IFilePathResolver.cs @@ -0,0 +1,37 @@ +namespace Werkr.Core.Security; + +/// +/// Resolves and validates file-system paths against the configured allowlist. +/// Provides shared utilities for wildcard resolution and source/destination +/// validation used by all built-in action handlers. +/// +public interface IFilePathResolver { + + /// + /// Resolves a single path to its full, normalized form and validates it + /// against the allowlist. Throws + /// if the path is outside the configured allowed prefixes. + /// + /// The path to resolve and validate. + /// The fully resolved, normalized path. + string ResolveSinglePath( string path ); + + /// + /// Resolves a wildcard source path (e.g. C:\data\*.txt), validates + /// each matched file against the allowlist, and returns the matched file paths. + /// Throws if any resolved path + /// falls outside the configured allowed prefixes. + /// + /// A file path that may contain wildcard characters. + /// An array of resolved, validated file paths. + string[] ResolveFiles( string source ); + + /// + /// Validates that two paths are not the same (case-aware per platform) and + /// resolves both to their full forms. Throws + /// if source and destination refer to the same path. + /// + /// The source path. + /// The destination path. + void ValidateSourceDestination( string source, string destination ); +} diff --git a/src/Werkr.Core/Security/IPathAllowlistValidator.cs b/src/Werkr.Core/Security/IPathAllowlistValidator.cs new file mode 100644 index 0000000..3cdb40e --- /dev/null +++ b/src/Werkr.Core/Security/IPathAllowlistValidator.cs @@ -0,0 +1,43 @@ +namespace Werkr.Core.Security; + +/// +/// Validates that file paths are within the configured allowlist. +/// When enforcement is disabled (the default), all paths are permitted. +/// When enforcement is enabled, paths outside all allowed prefixes are rejected. +/// +/// +/// Path allowlist enforcement occurs exclusively on the Agent. +/// The API layer cannot validate paths against allowlists because the +/// target agent is not known at task-creation time (tasks are dispatched +/// by tag matching). The Agent is the sole enforcement point. +/// +public interface IPathAllowlistValidator { + /// + /// Validates that is within the configured allowlist. + /// Throws with a descriptive message + /// if the path is outside all allowed prefixes and enforcement is enabled. + /// + /// The filesystem path to validate. + /// + /// Thrown when the path is outside the allowlist and enforcement is enabled. + /// + void ValidatePath( string path ); + + /// + /// Validates that all specified paths are within the configured allowlist. + /// Useful for operations involving source and destination (e.g. copy, move). + /// + /// The filesystem paths to validate. + /// + /// Thrown when any path is outside the allowlist and enforcement is enabled. + /// + void ValidatePaths( params string[] paths ); + + /// + /// Non-throwing check for whether is within the configured allowlist. + /// Returns true when enforcement is disabled or the path is within an allowed prefix. + /// + /// The filesystem path to check. + /// true if the path is permitted; false otherwise. + bool IsPathAllowed( string path ); +} diff --git a/src/Werkr.Core/Security/ISecretStore.cs b/src/Werkr.Core/Security/ISecretStore.cs new file mode 100644 index 0000000..dcf8a2a --- /dev/null +++ b/src/Werkr.Core/Security/ISecretStore.cs @@ -0,0 +1,21 @@ +namespace Werkr.Core.Security; + +/// +/// Cross-platform abstraction for securely storing secrets in the OS credential store. +/// Used to store the Agent's SQLCipher database passphrase. +/// +public interface ISecretStore { + /// Retrieves a secret value by key. Returns null if the key does not exist. + /// The key identifying the secret. + /// The secret value, or null if not found. + Task GetSecretAsync( string key ); + + /// Stores a secret value by key, overwriting any existing value. + /// The key identifying the secret. + /// The secret value to store. + Task SetSecretAsync( string key, string value ); + + /// Deletes a secret by key. No-op if the key does not exist. + /// The key identifying the secret. + Task DeleteSecretAsync( string key ); +} diff --git a/src/Werkr.Core/Security/LinuxSecretStore.cs b/src/Werkr.Core/Security/LinuxSecretStore.cs new file mode 100644 index 0000000..c182775 --- /dev/null +++ b/src/Werkr.Core/Security/LinuxSecretStore.cs @@ -0,0 +1,139 @@ +using System.Diagnostics; +using System.Runtime.Versioning; + +namespace Werkr.Core.Security; + +/// +/// Linux implementation of using libsecret +/// via the secret-tool command-line utility. Falls back to +/// file-based storage in ~/.config/werkr/secrets/ if +/// secret-tool is not available. +/// +[SupportedOSPlatform( "linux" )] +public class LinuxSecretStore : ISecretStore { + private const string SchemaAttribute = "werkr-key"; + private readonly bool _useSecretTool; + private readonly string _fallbackPath; + + /// Creates a new . + public LinuxSecretStore( ) { + _useSecretTool = IsSecretToolAvailable( ); + + string configDir = Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData ); + if (string.IsNullOrEmpty( configDir )) { + configDir = Path.Combine( + Environment.GetFolderPath( Environment.SpecialFolder.UserProfile ), + ".config" + ); + } + + _fallbackPath = Path.Combine( configDir, "werkr", "secrets" ); + if (!_useSecretTool) { + _ = Directory.CreateDirectory( _fallbackPath ); + // Restrict permissions: owner-only + File.SetUnixFileMode( _fallbackPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute ); + } + } + + /// + public async Task GetSecretAsync( string key ) { + if (_useSecretTool) { + (int exitCode, string stdout, _) = await RunSecretToolAsync( + "lookup", $"{SchemaAttribute} {key}" + ).ConfigureAwait( false ); + + return exitCode == 0 ? stdout.Trim( ) : null; + } + + string filePath = GetFallbackFilePath( key ); + return !File.Exists( filePath ) ? null : await File.ReadAllTextAsync( filePath ).ConfigureAwait( false ); + } + + /// + public async Task SetSecretAsync( string key, string value ) { + if (_useSecretTool) { + (int exitCode, _, string stderr) = await RunSecretToolAsync( + "store", $"--label=\"Werkr: {key}\" {SchemaAttribute} {key}", + stdinData: value + ).ConfigureAwait( false ); + + if (exitCode != 0) { + throw new InvalidOperationException( $"Failed to store secret via secret-tool: {stderr}" ); + } + + return; + } + + string filePath = GetFallbackFilePath( key ); + await File.WriteAllTextAsync( filePath, value ).ConfigureAwait( false ); + File.SetUnixFileMode( filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite ); + } + + /// + public async Task DeleteSecretAsync( string key ) { + if (_useSecretTool) { + _ = await RunSecretToolAsync( + "clear", $"{SchemaAttribute} {key}" + ).ConfigureAwait( false ); + return; + } + + string filePath = GetFallbackFilePath( key ); + if (File.Exists( filePath )) { + File.Delete( filePath ); + } + } + + private string GetFallbackFilePath( string key ) { + string safeKey = string.Join( "_", key.Split( Path.GetInvalidFileNameChars( ) ) ); + return Path.Combine( _fallbackPath, safeKey ); + } + + private static bool IsSecretToolAvailable( ) { + try { + ProcessStartInfo psi = new( ) { + FileName = "which", + Arguments = "secret-tool", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using Process process = new( ) { StartInfo = psi }; + _ = process.Start( ); + _ = process.WaitForExit( 3000 ); + return process.ExitCode == 0; + } catch { + return false; + } + } + + private static async Task<(int ExitCode, string StdOut, string StdErr)> RunSecretToolAsync( + string command, string arguments, string? stdinData = null ) { + ProcessStartInfo psi = new( ) { + FileName = "secret-tool", + Arguments = $"{command} {arguments}", + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = stdinData is not null, + UseShellExecute = false, + CreateNoWindow = true + }; + + using Process process = new( ) { StartInfo = psi }; + _ = process.Start( ); + + if (stdinData is not null) { + await process.StandardInput.WriteAsync( stdinData ).ConfigureAwait( false ); + process.StandardInput.Close( ); + } + + string stdout = await process.StandardOutput.ReadToEndAsync( ).ConfigureAwait( false ); + string stderr = await process.StandardError.ReadToEndAsync( ).ConfigureAwait( false ); + await process.WaitForExitAsync( ).ConfigureAwait( false ); + + return (process.ExitCode, stdout, stderr); + } +} diff --git a/src/Werkr.Core/Security/MacOsSecretStore.cs b/src/Werkr.Core/Security/MacOsSecretStore.cs new file mode 100644 index 0000000..d350c2e --- /dev/null +++ b/src/Werkr.Core/Security/MacOsSecretStore.cs @@ -0,0 +1,70 @@ +using System.Diagnostics; +using System.Runtime.Versioning; + +namespace Werkr.Core.Security; + +/// +/// macOS implementation of using the Keychain +/// via the security command-line tool. +/// +[SupportedOSPlatform( "osx" )] +public class MacOsSecretStore : ISecretStore { + private const string ServiceName = "Werkr"; + + /// + public async Task GetSecretAsync( string key ) { + (int exitCode, string stdout, _) = await RunSecurityAsync( + "find-generic-password", + $"-s \"{ServiceName}\" -a \"{key}\" -w" + ).ConfigureAwait( false ); + + return exitCode != 0 ? null : stdout.Trim( ); + } + + /// + public async Task SetSecretAsync( string key, string value ) { + // Delete existing entry first (ignore errors if it doesn't exist) + _ = await RunSecurityAsync( + "delete-generic-password", + $"-s \"{ServiceName}\" -a \"{key}\"" + ).ConfigureAwait( false ); + + (int exitCode, _, string stderr) = await RunSecurityAsync( + "add-generic-password", + $"-s \"{ServiceName}\" -a \"{key}\" -w \"{value}\" -U" + ).ConfigureAwait( false ); + + if (exitCode != 0) { + throw new InvalidOperationException( $"Failed to store secret in Keychain: {stderr}" ); + } + } + + /// + public async Task DeleteSecretAsync( string key ) { + _ = await RunSecurityAsync( + "delete-generic-password", + $"-s \"{ServiceName}\" -a \"{key}\"" + ).ConfigureAwait( false ); + } + + private static async Task<(int ExitCode, string StdOut, string StdErr)> RunSecurityAsync( + string command, string arguments ) { + ProcessStartInfo psi = new( ) { + FileName = "security", + Arguments = $"{command} {arguments}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using Process process = new( ) { StartInfo = psi }; + _ = process.Start( ); + + string stdout = await process.StandardOutput.ReadToEndAsync( ).ConfigureAwait( false ); + string stderr = await process.StandardError.ReadToEndAsync( ).ConfigureAwait( false ); + await process.WaitForExitAsync( ).ConfigureAwait( false ); + + return (process.ExitCode, stdout, stderr); + } +} diff --git a/src/Werkr.Core/Security/SecretStoreFactory.cs b/src/Werkr.Core/Security/SecretStoreFactory.cs new file mode 100644 index 0000000..92a1cd0 --- /dev/null +++ b/src/Werkr.Core/Security/SecretStoreFactory.cs @@ -0,0 +1,27 @@ +using System.Runtime.InteropServices; + +namespace Werkr.Core.Security; + +/// +/// Factory that creates the platform-appropriate +/// implementation based on the current operating system. +/// +public static class SecretStoreFactory { + /// + /// Creates the appropriate for the current platform. + /// + /// A platform-specific instance. + /// + /// Thrown when the current operating system is not supported. + /// + public static ISecretStore Create( ) { + return RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) + ? new WindowsSecretStore( ) + : RuntimeInformation.IsOSPlatform( OSPlatform.OSX ) + ? new MacOsSecretStore( ) + : RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) + ? (ISecretStore)new LinuxSecretStore( ) + : throw new PlatformNotSupportedException( + "Werkr secret store is not supported on the current operating system." ); + } +} diff --git a/src/Werkr.Core/Security/WindowsSecretStore.cs b/src/Werkr.Core/Security/WindowsSecretStore.cs new file mode 100644 index 0000000..bd15b0e --- /dev/null +++ b/src/Werkr.Core/Security/WindowsSecretStore.cs @@ -0,0 +1,59 @@ +using System.Runtime.Versioning; +using System.Security.Cryptography; + +namespace Werkr.Core.Security; + +/// +/// Windows implementation of using DPAPI +/// () with . +/// Encrypted blobs are stored to %LOCALAPPDATA%\Werkr\secrets\{key}.bin. +/// +[SupportedOSPlatform( "windows" )] +public class WindowsSecretStore : ISecretStore { + private readonly string _basePath; + + /// Creates a new . + public WindowsSecretStore( ) { + string localAppData = Environment.GetFolderPath( Environment.SpecialFolder.LocalApplicationData ); + _basePath = Path.Combine( localAppData, "Werkr", "secrets" ); + _ = Directory.CreateDirectory( _basePath ); + } + + /// + public Task GetSecretAsync( string key ) { + string filePath = GetFilePath( key ); + if (!File.Exists( filePath )) { + return Task.FromResult( null ); + } + + byte[] encryptedBytes = File.ReadAllBytes( filePath ); + byte[] decryptedBytes = ProtectedData.Unprotect( encryptedBytes, null, DataProtectionScope.CurrentUser ); + string value = System.Text.Encoding.UTF8.GetString( decryptedBytes ); + return Task.FromResult( value ); + } + + /// + public Task SetSecretAsync( string key, string value ) { + byte[] plainBytes = System.Text.Encoding.UTF8.GetBytes( value ); + byte[] encryptedBytes = ProtectedData.Protect( plainBytes, null, DataProtectionScope.CurrentUser ); + string filePath = GetFilePath( key ); + File.WriteAllBytes( filePath, encryptedBytes ); + return Task.CompletedTask; + } + + /// + public Task DeleteSecretAsync( string key ) { + string filePath = GetFilePath( key ); + if (File.Exists( filePath )) { + File.Delete( filePath ); + } + + return Task.CompletedTask; + } + + private string GetFilePath( string key ) { + // Sanitize key for file system + string safeKey = string.Join( "_", key.Split( Path.GetInvalidFileNameChars( ) ) ); + return Path.Combine( _basePath, safeKey + ".bin" ); + } +} diff --git a/src/Werkr.Core/Tasks/AgentResolver.cs b/src/Werkr.Core/Tasks/AgentResolver.cs new file mode 100644 index 0000000..e1de27e --- /dev/null +++ b/src/Werkr.Core/Tasks/AgentResolver.cs @@ -0,0 +1,178 @@ +using Grpc.Core; +using Grpc.Net.Client; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Data; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Core.Tasks; + +/// +/// Resolves a target agent for task execution based on tag-based matching. +/// An agent is eligible when any of its +/// matches any of the task's TargetTags (case-insensitive). +/// +/// Database context for querying registered connections. +/// Singleton gRPC channel cache for live health checks. +/// Logger instance. +public sealed class AgentResolver( WerkrDbContext dbContext, AgentConnectionManager connectionManager, ILogger logger ) { + + /// + /// Finds a connected agent whose tags intersect with the specified target tags. + /// First checks agents with Status == Connected. If none are found, + /// performs a live gRPC health check against all non-revoked matching agents + /// to recover agents that came back online between health sweeps. + /// + /// The tags to match against agent tags. + /// Cancellation token. + /// A matching , or null if no agents match. + public async Task ResolveAsync( string[] targetTags, CancellationToken ct = default ) { + if (targetTags.Length == 0) { + logger.LogWarning( "No target tags specified for agent resolution." ); + return null; + } + + // Normalize target tags for case-insensitive comparison + HashSet normalizedTargets = new( + targetTags.Select( t => t.Trim( ) ), + StringComparer.OrdinalIgnoreCase ); + + // Load connected server-side agents with tags + List connectedAgents = await dbContext.RegisteredConnections + .Where( c => c.IsServer && c.Status == Common.Models.ConnectionStatus.Connected ) + .ToListAsync( ct ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( + "AgentResolver found {AgentCount} connected agents. Target tags: [{Tags}].", + connectedAgents.Count, string.Join( ", ", targetTags ) ); + } + + // Find first connected agent with intersecting tags (in-memory for JSON column compatibility) + RegisteredConnection? match = connectedAgents.FirstOrDefault( agent => + agent.Tags.Any( tag => normalizedTargets.Contains( tag.Trim( ) ) ) ); + + // If no connected match, try live health check against non-revoked agents with matching tags + match ??= await TryLiveResolveAsync( normalizedTargets, ct ); + + if (match is null) { + if (logger.IsEnabled( LogLevel.Warning )) { + logger.LogWarning( "No connected agent found matching tags: [{Tags}].", + string.Join( ", ", targetTags ) ); + } + } else { + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Resolved agent {AgentId} ({AgentName}) for tags [{Tags}].", + match.Id.ToString( ), match.ConnectionName, string.Join( ", ", targetTags ) ); + } + } + + return match; + } + + /// + /// Finds all connected agents whose tags intersect with the specified target tags. + /// + /// The tags to match against agent tags. + /// Cancellation token. + /// A list of matching instances. + public async Task> ResolveAllAsync( string[] targetTags, CancellationToken ct = default ) { + if (targetTags.Length == 0) { + return []; + } + + HashSet normalizedTargets = new( + targetTags.Select( t => t.Trim( ) ), + StringComparer.OrdinalIgnoreCase ); + + List agents = await dbContext.RegisteredConnections + .Where( c => c.IsServer && c.Status == Common.Models.ConnectionStatus.Connected ) + .ToListAsync( ct ); + + return [.. agents.Where( agent => agent.Tags.Any( tag => normalizedTargets.Contains( tag.Trim( ) ) ) )]; + } + + /// + /// Performs a live gRPC health check against non-revoked agents with matching tags + /// that are NOT currently marked as Connected. If an agent responds, updates its + /// DB status to Connected and returns it. + /// + private async Task TryLiveResolveAsync( + HashSet normalizedTargets, + CancellationToken ct ) { + + // Get non-revoked, non-connected agents + List candidates = await dbContext.RegisteredConnections + .Where( c => c.IsServer + && c.Status != Common.Models.ConnectionStatus.Connected + && c.Status != Common.Models.ConnectionStatus.Revoked ) + .ToListAsync( ct ); + + // Filter by tags in-memory + List tagMatches = [.. candidates.Where( agent => + agent.Tags.Any( tag => normalizedTargets.Contains( tag.Trim( ) ) ) )]; + + if (tagMatches.Count == 0) { + return null; + } + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( + "AgentResolver attempting live health check on {Count} non-connected candidate(s).", + tagMatches.Count ); + } + + foreach (RegisteredConnection candidate in tagMatches) { + try { + (GrpcChannel channel, RegisteredConnection resolved) = + await connectionManager.GetChannelAsync( candidate.Id, ct ); + + string keyId = resolved.ActiveKeyId ?? resolved.Id.ToString( ); + HeartbeatRequest heartbeat = new( ) { StatusMessage = "live-resolve" }; + EncryptedEnvelope requestEnvelope = PayloadEncryptor.EncryptToEnvelope( + heartbeat, resolved.SharedKey, keyId ); + + ConnectionManagement.ConnectionManagementClient client = new( channel ); + EncryptedEnvelope responseEnvelope = await client.HeartbeatAsync( + requestEnvelope, + AgentConnectionManager.CreateCallOptions( + resolved, + timeout: TimeSpan.FromSeconds( 5 ), + cancellationToken: ct ) ); + + // Decrypt to validate shared key + HeartbeatResponse heartbeatResponse = PayloadEncryptor.DecryptFromEnvelope( + responseEnvelope, resolved.SharedKey ); + + // Agent responded — update DB status and return it + candidate.Status = Common.Models.ConnectionStatus.Connected; + candidate.LastSeen = DateTime.UtcNow; + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "AgentResolver recovered agent {AgentId} ({Name}) via live health check.", + candidate.Id, candidate.ConnectionName ); + } + + return candidate; + } catch (OperationCanceledException) { + throw; + } catch (RpcException) { + // Agent still unreachable — skip + } catch (Exception ex) { + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( ex, + "AgentResolver live probe failed for {AgentId}.", + candidate.Id ); + } + } + } + + return null; + } +} diff --git a/src/Werkr.Core/Tasks/JobExecutionService.cs b/src/Werkr.Core/Tasks/JobExecutionService.cs new file mode 100644 index 0000000..0941624 --- /dev/null +++ b/src/Werkr.Core/Tasks/JobExecutionService.cs @@ -0,0 +1,324 @@ +using System.Diagnostics; +using System.Text.Json; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Data; +using Werkr.Data.Entities.Registration; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Core.Tasks; + +/// +/// Orchestrates the execution of a as a . +/// Creates the job record, dispatches to the resolved agent, captures output, +/// evaluates success criteria, and finalizes the job record. +/// +/// Database context. +/// Command dispatcher for sending commands to agents. +/// Resolves agents by tag matching. +/// Writes job output to disk. +/// Evaluates success criteria against results. +/// Logger instance. +public sealed class JobExecutionService( + WerkrDbContext dbContext, + ICommandDispatcher commandDispatcher, + AgentResolver agentResolver, + JobOutputWriter outputWriter, + SuccessCriteriaEvaluator criteriaEvaluator, + ILogger logger ) { + + /// + /// Executes a task by ID: resolves an agent, creates a job record, + /// dispatches the command/script, captures output, and evaluates success. + /// + /// The task identifier to execute. + /// Cancellation token. + /// The finalized record. + /// Task not found. + /// No matching agent available. + public async Task ExecuteAsync( long taskId, CancellationToken ct = default ) { + // Load the task + WerkrTask task = await dbContext.Tasks.AsNoTracking( ).FirstOrDefaultAsync( t => t.Id == taskId, ct ) + ?? throw new KeyNotFoundException( $"Task with Id={taskId} was not found." ); + + return await ExecuteAsync( task, ct ); + } + + /// + /// Executes a task: resolves an agent, creates a job record, + /// dispatches the command/script, captures output, and evaluates success. + /// + /// The task to execute. + /// Cancellation token. + /// The finalized record. + /// No matching agent available. + public async Task ExecuteAsync( WerkrTask task, CancellationToken ct = default ) { + // Resolve agent + RegisteredConnection agent = await agentResolver.ResolveAsync( task.TargetTags, ct ) + ?? throw new InvalidOperationException( + $"No connected agent found matching tags [{string.Join( ", ", task.TargetTags )}]." ); + + return await ExecuteOnAgentAsync( task, agent, workflowRunId: null, ct ); + } + + /// + /// Executes a task on a specific pre-resolved agent. Creates a job record, + /// dispatches the command/script, captures output, and evaluates success. + /// Used by after agent resolution, + /// and by WorkflowExecutor which resolves agents per-step. + /// + /// The task to execute. + /// The pre-resolved agent connection to execute on. + /// Optional workflow run ID to associate the job with. + /// Cancellation token. + /// The finalized record. + internal async Task ExecuteOnAgentAsync( + WerkrTask task, + RegisteredConnection agent, + Guid? workflowRunId, + CancellationToken ct = default ) { + // Create the job record immediately (in-flight visibility) + WerkrJob job = new( ) { + TaskId = task.Id, + TaskSnapshot = task.Content, + StartTime = DateTime.UtcNow, + AgentConnectionId = agent.Id, + WorkflowRunId = workflowRunId, + OutputPath = $"{Guid.Empty}.log", // placeholder until Id is generated + }; + _ = dbContext.Jobs.Add( job ); + _ = await dbContext.SaveChangesAsync( ct ); + + // Update output path with actual job Id + job.OutputPath = $"{job.Id}.log"; + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Executing task {TaskId} '{TaskName}' as job {JobId} on agent {AgentId}.", + task.Id.ToString( ), task.Name, job.Id.ToString( ), agent.Id.ToString( ) ); + } + + // Set up timeout + int timeoutMinutes = (int) ( task.TimeoutMinutes ?? 30 ); + using CancellationTokenSource timeoutCts = new( TimeSpan.FromMinutes( timeoutMinutes ) ); + using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource( ct, timeoutCts.Token ); + + Stopwatch stopwatch = Stopwatch.StartNew( ); + List collectedOutput = []; + int? exitCode = null; + Exception? executionException = null; + ErrorCategory errorCategory = ErrorCategory.None; + + try { + // Map TaskActionType to OperatorType and dispatch + IAsyncEnumerable outputStream = DispatchTask( task, agent.Id, linkedCts.Token ); + + // Consume output stream, writing each line to disk incrementally + await foreach (OperatorOutput output in outputStream.WithCancellation( linkedCts.Token )) { + collectedOutput.Add( output ); + await outputWriter.WriteLineAsync( job.Id, output, CancellationToken.None ); + } + + // Try to extract exit code from output (convention: last line with "ExitCode: N") + exitCode = ExtractExitCode( collectedOutput ); + } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { + errorCategory = ErrorCategory.Timeout; + executionException = new TimeoutException( + $"Job {job.Id} timed out after {timeoutMinutes} minutes." ); + logger.LogWarning( "Job {JobId} timed out after {Timeout} minutes.", job.Id.ToString( ), timeoutMinutes.ToString( ) ); + } catch (CommandDispatcherException cde) { + errorCategory = MapDispatchFailure( cde.Reason ); + executionException = cde; + logger.LogError( cde, "Job {JobId} dispatch failed: {Reason}.", job.Id.ToString( ), cde.Reason.ToString( ) ); + } catch (OperationCanceledException ex) when (ct.IsCancellationRequested) { + errorCategory = ErrorCategory.Unknown; + executionException = ex; + logger.LogWarning( "Job {JobId} was cancelled.", job.Id.ToString( ) ); + } catch (Exception ex) { + errorCategory = ErrorCategory.ScriptError; + executionException = ex; + logger.LogError( ex, "Job {JobId} failed with unexpected error.", job.Id.ToString( ) ); + } + + stopwatch.Stop( ); + + // Evaluate success + bool success = criteriaEvaluator.Evaluate( + task.ActionType, task.SuccessCriteria, exitCode, collectedOutput, executionException ); + + // Get tail preview + string? tailPreview = await outputWriter.GetTailPreviewAsync( job.Id, CancellationToken.None ); + + // Finalize the job record + job.EndTime = DateTime.UtcNow; + job.RuntimeSeconds = stopwatch.Elapsed.TotalSeconds; + job.Success = success; + job.ExitCode = exitCode; + job.ErrorCategory = errorCategory; + job.Output = tailPreview; + + _ = await dbContext.SaveChangesAsync( CancellationToken.None ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Job {JobId} completed: Success={Success}, ExitCode={ExitCode}, Runtime={Runtime:F1}s, ErrorCategory={ErrorCategory}.", + job.Id.ToString( ), success.ToString( ), exitCode?.ToString( ) ?? "null", + stopwatch.Elapsed.TotalSeconds, errorCategory.ToString( ) ); + } + + return job; + } + + /// + /// Retrieves job history for a task, ordered by most recent first. + /// + /// The task identifier. + /// Maximum number of jobs to return. + /// Cancellation token. + /// A list of jobs for the specified task. + public async Task> GetJobHistoryAsync( long taskId, int limit = 50, CancellationToken ct = default ) => + await dbContext.Jobs.AsNoTracking( ) + .Include( j => j.Task ) + .Include( j => j.AgentConnection ) + .Where( j => j.TaskId == taskId ) + .OrderByDescending( j => j.StartTime ) + .Take( limit ) + .ToListAsync( ct ); + + /// + /// Retrieves recent jobs across all tasks, ordered by most recent first. + /// Supports optional filtering by success status and date range. + /// + /// Optional filter — true for successful, false for failed, null for all. + /// Optional start of date/time window (UTC). + /// Optional end of date/time window (UTC). + /// Maximum number of jobs to return. + /// Cancellation token. + /// A list of recent jobs. + public async Task> GetRecentJobsAsync( + bool? success = null, + DateTime? since = null, + DateTime? until = null, + int limit = 50, + CancellationToken ct = default ) { + IQueryable query = dbContext.Jobs.AsNoTracking( ) + .Include( j => j.Task ) + .Include( j => j.AgentConnection ); + + if (success.HasValue) { + query = query.Where( j => j.Success == success.Value ); + } + + if (since.HasValue) { + query = query.Where( j => j.StartTime >= since.Value ); + } + + if (until.HasValue) { + query = query.Where( j => j.StartTime <= until.Value ); + } + + return await query + .OrderByDescending( j => j.StartTime ) + .Take( limit ) + .ToListAsync( ct ); + } + + /// + /// Retrieves a single job by ID. + /// + /// The job identifier. + /// Cancellation token. + /// The job, or null if not found. + public async Task GetJobAsync( Guid jobId, CancellationToken ct = default ) => + await dbContext.Jobs.AsNoTracking( ).FirstOrDefaultAsync( j => j.Id == jobId, ct ); + + /// + /// Retrieves the full output for a job from disk. + /// + /// The job identifier. + /// Cancellation token. + /// The full output text, or null if the file does not exist. + public async Task GetJobOutputAsync( Guid jobId, CancellationToken ct = default ) => + await outputWriter.ReadFullOutputAsync( jobId, ct ); + + /// + /// Maps a to an + /// and dispatches the appropriate command or script. + /// + private IAsyncEnumerable DispatchTask( + WerkrTask task, Guid agentConnectionId, CancellationToken ct ) { + + return task.ActionType switch { + TaskActionType.PowerShellCommand => + commandDispatcher.ExecuteCommandAsync( agentConnectionId, OperatorType.PowerShell, task.Content, ct ), + + TaskActionType.PowerShellScript => + task.Arguments is { Length: > 0 } + ? commandDispatcher.ExecuteScriptAsync( agentConnectionId, OperatorType.PowerShell, task.Content, task.Arguments, ct ) + : commandDispatcher.ExecuteScriptAsync( agentConnectionId, OperatorType.PowerShell, task.Content, null, ct ), + + TaskActionType.ShellCommand => + commandDispatcher.ExecuteCommandAsync( agentConnectionId, OperatorType.SystemShell, task.Content, ct ), + + TaskActionType.ShellScript => + task.Arguments is { Length: > 0 } + ? commandDispatcher.ExecuteScriptAsync( agentConnectionId, OperatorType.SystemShell, task.Content, task.Arguments, ct ) + : commandDispatcher.ExecuteScriptAsync( agentConnectionId, OperatorType.SystemShell, task.Content, null, ct ), + + TaskActionType.Action => + commandDispatcher.ExecuteActionAsync( agentConnectionId, + CreateActionDescriptor( task ), ct ), + + _ => throw new InvalidOperationException( $"Unsupported action type: {task.ActionType}." ) + }; + } + + private static ActionDescriptor CreateActionDescriptor( WerkrTask task ) { + using JsonDocument parsedParameters = JsonDocument.Parse( task.ActionParameters ?? "{}" ); + return new ActionDescriptor { + Action = task.ActionSubType ?? throw new InvalidOperationException( "ActionSubType is required for Action tasks." ), + Parameters = parsedParameters.RootElement.Clone( ), + }; + } + + /// + /// Maps to . + /// + private static ErrorCategory MapDispatchFailure( CommandDispatchFailure failure ) => + failure switch { + CommandDispatchFailure.AgentNotFound => ErrorCategory.AgentUnreachable, + CommandDispatchFailure.AgentRevoked => ErrorCategory.AgentUnreachable, + CommandDispatchFailure.AgentUnreachable => ErrorCategory.AgentUnreachable, + CommandDispatchFailure.TlsError => ErrorCategory.AgentUnreachable, + CommandDispatchFailure.EncryptionError => ErrorCategory.Unknown, + _ => ErrorCategory.Unknown + }; + + /// + /// Attempts to extract an exit code from the output stream. + /// Looks for the last output line that matches the convention used by + /// the operators: an Information-level line ending with exited with code N. + /// + private static int? ExtractExitCode( List output ) { + // Look for exit code in reverse order (most likely in last few lines) + for (int i = output.Count - 1; i >= Math.Max( 0, output.Count - 10 ); i--) { + string message = output[i].Message; + + // Pattern: "Process exited with code 0" or "exited with code 123" + int idx = message.LastIndexOf( "exited with code ", StringComparison.OrdinalIgnoreCase ); + if (idx >= 0) { + string codeStr = message[( idx + "exited with code ".Length )..].Trim( ); + if (int.TryParse( codeStr, out int code )) { + return code; + } + } + } + + return null; + } +} diff --git a/src/Werkr.Core/Tasks/JobOutputWriter.cs b/src/Werkr.Core/Tasks/JobOutputWriter.cs new file mode 100644 index 0000000..cb985f5 --- /dev/null +++ b/src/Werkr.Core/Tasks/JobOutputWriter.cs @@ -0,0 +1,103 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using Werkr.Common.Configuration; +using Werkr.Core.Communication; + +namespace Werkr.Core.Tasks; + +/// +/// Writes job output to individual log files on disk. +/// Each job gets a file named {JobId}.log in the configured output directory. +/// Output is appended incrementally, so partial output is preserved if the job crashes. +/// +/// Job output configuration. +/// Logger instance. +public sealed class JobOutputWriter( IOptions options, ILogger logger ) { + + private readonly string _outputDirectory = options.Value.OutputDirectory; + private readonly int _tailPreviewLength = options.Value.TailPreviewLength; + + /// + /// Ensures the output directory exists. Called once at service startup or first use. + /// + public void EnsureDirectoryExists( ) { + if (!Directory.Exists( _outputDirectory )) { + _ = Directory.CreateDirectory( _outputDirectory ); + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Created job output directory: {Directory}", _outputDirectory ); + } + } + } + + /// + /// Returns the full file path for a job's output log. + /// + /// The job identifier. + /// Absolute or relative path to the log file. + public string GetOutputPath( Guid jobId ) => + Path.Combine( _outputDirectory, $"{jobId}.log" ); + + /// + /// Writes an operator output line to the job's log file, appending to any existing content. + /// Format: [timestamp] [level] message + /// + /// The job identifier. + /// The operator output record to write. + /// Cancellation token. + public async Task WriteLineAsync( Guid jobId, OperatorOutput output, CancellationToken ct = default ) { + EnsureDirectoryExists( ); + string filePath = GetOutputPath( jobId ); + string line = FormatLine( output ); + await File.AppendAllTextAsync( filePath, line + Environment.NewLine, ct ); + } + + /// + /// Writes multiple operator output lines to the job's log file. + /// + /// The job identifier. + /// The operator output records to write. + /// Cancellation token. + public async Task WriteLinesAsync( Guid jobId, IEnumerable outputs, CancellationToken ct = default ) { + EnsureDirectoryExists( ); + string filePath = GetOutputPath( jobId ); + IEnumerable lines = outputs.Select( o => FormatLine( o ) ); + await File.AppendAllLinesAsync( filePath, lines, ct ); + } + + /// + /// Reads the full output file for a job. Returns null if the file does not exist. + /// + /// The job identifier. + /// Cancellation token. + /// Full file contents, or null if no output was written. + public async Task ReadFullOutputAsync( Guid jobId, CancellationToken ct = default ) { + string filePath = GetOutputPath( jobId ); + return !File.Exists( filePath ) ? null : await File.ReadAllTextAsync( filePath, ct ); + } + + /// + /// Extracts the tail preview (last N characters) from a job's output file. + /// Uses the configured . + /// + /// The job identifier. + /// Cancellation token. + /// The tail preview string, or null if no output exists. + public async Task GetTailPreviewAsync( Guid jobId, CancellationToken ct = default ) { + string filePath = GetOutputPath( jobId ); + if (!File.Exists( filePath )) { + return null; + } + + string content = await File.ReadAllTextAsync( filePath, ct ); + return content.Length <= _tailPreviewLength + ? content + : content[^_tailPreviewLength..]; + } + + /// + /// Formats an record into a log line. + /// + private static string FormatLine( OperatorOutput output ) => + $"[{output.Timestamp}] [{output.LogLevel}] {output.Message}"; +} diff --git a/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs b/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs new file mode 100644 index 0000000..08b1bb3 --- /dev/null +++ b/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs @@ -0,0 +1,163 @@ +using Microsoft.Extensions.Logging; + +using Werkr.Core.Communication; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Core.Tasks; + +/// +/// Evaluates whether a job's execution result satisfies the task's success criteria. +/// When SuccessCriteria is null, defaults are inferred from . +/// Custom criteria expressions are predefined string keys evaluated against the typed result. +/// +/// Logger instance. +public sealed class SuccessCriteriaEvaluator( ILogger logger ) { + + /// + /// Evaluates success for a completed job. + /// + /// The task's action type (determines default criteria). + /// + /// Optional criteria expression. When null, defaults are inferred from . + /// Supported expressions: + /// + /// exitCode == 0 — exit code must be zero + /// pwsh.HadErrors == false — PowerShell had no errors + /// output.contains("TEXT") — output must contain the specified text + /// always — always succeed (useful for fire-and-forget tasks) + /// + /// + /// The process/shell exit code, if available. + /// The collected output lines from the job execution. + /// Any exception that occurred during execution. + /// true if the job meets the success criteria; otherwise false. + public bool Evaluate( + TaskActionType actionType, + string? successCriteria, + int? exitCode, + IReadOnlyList output, + Exception? exception ) { + + // An unhandled exception always means failure, unless criteria is "always" + if (exception is not null && !string.Equals( successCriteria, "always", StringComparison.OrdinalIgnoreCase )) { + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Job failed due to exception: {Message}", exception.Message ); + } + return false; + } + + // If explicit criteria provided, evaluate it + if (!string.IsNullOrWhiteSpace( successCriteria )) { + return EvaluateExpression( successCriteria, exitCode, output, exception ); + } + + // Default criteria based on action type + return EvaluateDefault( actionType, exitCode, output ); + } + + /// + /// Returns a human-readable description of the effective success criteria + /// that will be used for a given task configuration. + /// + /// The task's action type. + /// The task's explicit criteria, if any. + /// A description of what will be evaluated. + public static string DescribeEffectiveCriteria( TaskActionType actionType, string? successCriteria ) { + return !string.IsNullOrWhiteSpace( successCriteria ) + ? successCriteria + : actionType switch { + TaskActionType.PowerShellCommand or TaskActionType.PowerShellScript => + "pwsh.HadErrors == false (default)", + TaskActionType.ShellCommand or TaskActionType.ShellScript => + "exitCode == 0 (default)", + TaskActionType.Action => + "always (default)", + _ => "unknown" + }; + } + + /// + /// Evaluates a predefined criteria expression against the result. + /// + private bool EvaluateExpression( + string criteria, + int? exitCode, + IReadOnlyList output, + Exception? exception ) { + + string trimmed = criteria.Trim( ); + + // "always" — always succeed + if (string.Equals( trimmed, "always", StringComparison.OrdinalIgnoreCase )) { + return true; + } + + // "exitCode == 0" — exit code must be zero + if (string.Equals( trimmed, "exitCode == 0", StringComparison.OrdinalIgnoreCase )) { + bool success = exitCode.HasValue && exitCode.Value == 0; + if (!success && logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Criteria 'exitCode == 0' failed: exitCode={ExitCode}.", exitCode?.ToString( ) ?? "null" ); + } + return success; + } + + // "pwsh.HadErrors == false" — PowerShell had no errors + if (string.Equals( trimmed, "pwsh.HadErrors == false", StringComparison.OrdinalIgnoreCase )) { + // When server-side: no direct PwshOperatorResult access. + // We infer from output — Error-level messages indicate HadErrors. + bool hadErrors = output.Any( o => + string.Equals( o.LogLevel, "Error", StringComparison.OrdinalIgnoreCase ) ); + bool success = !hadErrors && exception is null; + if (!success && logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Criteria 'pwsh.HadErrors == false' failed: hadErrors={HadErrors}, exception={HasException}.", + hadErrors.ToString( ), (exception is not null).ToString( ) ); + } + return success; + } + + // "output.contains("TEXT")" — output must contain the specified text + if (trimmed.StartsWith( "output.contains(", StringComparison.OrdinalIgnoreCase ) + && trimmed.EndsWith( ')' )) { + string inner = trimmed["output.contains(".Length..^1]; + // Strip surrounding quotes if present + if (inner.Length >= 2 && inner[0] == '"' && inner[^1] == '"') { + inner = inner[1..^1]; + } + bool success = output.Any( o => + o.Message.Contains( inner, StringComparison.OrdinalIgnoreCase ) ); + if (!success && logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Criteria 'output.contains(\"{Text}\")' failed: text not found in {LineCount} lines.", + inner, output.Count.ToString( ) ); + } + return success; + } + + // Unknown criteria — log warning and fall through to default success + logger.LogWarning( "Unknown success criteria expression: '{Criteria}'. Falling back to default.", trimmed ); + return exitCode is null or 0; + } + + /// + /// Evaluates the default success criteria based on the action type. + /// + private bool EvaluateDefault( + TaskActionType actionType, + int? exitCode, + IReadOnlyList output ) { + + return actionType switch { + // PowerShell: success when no Error-level output + TaskActionType.PowerShellCommand or TaskActionType.PowerShellScript => + !output.Any( o => string.Equals( o.LogLevel, "Error", StringComparison.OrdinalIgnoreCase ) ), + + // Shell: success when exit code is 0 + TaskActionType.ShellCommand or TaskActionType.ShellScript => + exitCode.HasValue && exitCode.Value == 0, + + // Actions: always succeed (built-in actions handle their own status) + TaskActionType.Action => true, + + _ => exitCode is null or 0 + }; + } +} diff --git a/src/Werkr.Core/Tasks/TaskService.cs b/src/Werkr.Core/Tasks/TaskService.cs new file mode 100644 index 0000000..8f0bc04 --- /dev/null +++ b/src/Werkr.Core/Tasks/TaskService.cs @@ -0,0 +1,170 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Werkr.Data; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Core.Tasks; + +/// +/// Provides CRUD operations for entities, +/// mediating between the API layer and the underlying . +/// +/// Database context. +/// Logger instance. +public sealed class TaskService( WerkrDbContext dbContext, ILogger logger ) { + + /// + /// Creates a new task with randomized . + /// + /// The task entity to create. Id is generated. + /// Cancellation token. + /// The created task with its generated Id. + /// Thrown when the task fails validation. + public async Task CreateAsync( WerkrTask task, CancellationToken ct = default ) { + Validate( task ); + + // Randomize sync interval between 30–60 minutes + task.SyncIntervalMinutes = RandomNumberGenerator.GetInt32( 30, 61 ); + + _ = dbContext.Tasks.Add( task ); + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Created task {TaskId} '{TaskName}' (SyncInterval={Interval}m).", + task.Id.ToString( ), task.Name, task.SyncIntervalMinutes.ToString( ) ); + } + + return task; + } + + /// + /// Retrieves all tasks, optionally filtered by workflow. + /// + /// If specified, only returns tasks belonging to this workflow. + /// Cancellation token. + /// A read-only list of tasks. + public async Task> GetAllAsync( long? workflowId = null, CancellationToken ct = default ) { + IQueryable query = dbContext.Tasks.AsNoTracking( ); + + if (workflowId.HasValue) { + query = query.Where( t => t.WorkflowId == workflowId.Value ); + } + + return await query.OrderBy( t => t.Name ).ToListAsync( ct ); + } + + /// + /// Retrieves a single task by ID. + /// + /// The task identifier. + /// Cancellation token. + /// The task, or null if not found. + public async Task GetByIdAsync( long id, CancellationToken ct = default ) => + await dbContext.Tasks.AsNoTracking( ).FirstOrDefaultAsync( t => t.Id == id, ct ); + + /// + /// Updates an existing task. + /// + /// The task entity with updated values. Id must match an existing task. + /// Cancellation token. + /// The updated task. + /// Thrown when the task ID does not exist. + /// Thrown when the task fails validation. + public async Task UpdateAsync( WerkrTask task, CancellationToken ct = default ) { + Validate( task ); + + WerkrTask? existing = await dbContext.Tasks.FirstOrDefaultAsync( t => t.Id == task.Id, ct ) + ?? throw new KeyNotFoundException( $"Task with Id={task.Id} was not found." ); + + existing.Name = task.Name; + existing.Description = task.Description; + existing.ActionType = task.ActionType; + existing.Content = task.Content; + existing.Arguments = task.Arguments; + existing.TargetTags = task.TargetTags; + existing.Enabled = task.Enabled; + existing.TimeoutMinutes = task.TimeoutMinutes; + existing.SuccessCriteria = task.SuccessCriteria; + existing.ScheduleId = task.ScheduleId; + existing.WorkflowId = task.WorkflowId; + existing.ActionSubType = task.ActionSubType; + existing.ActionParameters = task.ActionParameters; + + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Updated task {TaskId} '{TaskName}'.", existing.Id.ToString( ), existing.Name ); + } + + return existing; + } + + /// + /// Deletes a task by ID. + /// + /// The task identifier. + /// Cancellation token. + /// Thrown when the task ID does not exist. + public async Task DeleteAsync( long id, CancellationToken ct = default ) { + WerkrTask? existing = await dbContext.Tasks.FirstOrDefaultAsync( t => t.Id == id, ct ) + ?? throw new KeyNotFoundException( $"Task with Id={id} was not found." ); + + _ = dbContext.Tasks.Remove( existing ); + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Deleted task {TaskId} '{TaskName}'.", id.ToString( ), existing.Name ); + } + } + + /// + /// Toggles the flag on a task. + /// + /// The task identifier. + /// The new enabled state. + /// Cancellation token. + /// Thrown when the task ID does not exist. + public async Task SetEnabledAsync( long id, bool enabled, CancellationToken ct = default ) { + WerkrTask? existing = await dbContext.Tasks.FirstOrDefaultAsync( t => t.Id == id, ct ) + ?? throw new KeyNotFoundException( $"Task with Id={id} was not found." ); + + existing.Enabled = enabled; + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Task {TaskId} enabled={Enabled}.", id.ToString( ), enabled.ToString( ) ); + } + } + + /// + /// Validates a before create or update. + /// + /// Thrown when validation fails. + private static void Validate( WerkrTask task ) { + if (string.IsNullOrWhiteSpace( task.Name )) { + throw new ValidationException( "Task name is required." ); + } + + // Action-type tasks use ActionSubType + ActionParameters instead of Content. + // Content is only required for shell-type tasks (ShellCommand, PowerShell, etc.). + if (task.ActionType != TaskActionType.Action && string.IsNullOrWhiteSpace( task.Content )) { + throw new ValidationException( "Task content is required." ); + } + + if (!Enum.IsDefined( task.ActionType )) { + throw new ValidationException( $"Invalid ActionType: {task.ActionType}." ); + } + + if (task.TargetTags.Length == 0) { + throw new ValidationException( "At least one target tag is required." ); + } + + if (task.TimeoutMinutes.HasValue && task.TimeoutMinutes.Value <= 0) { + throw new ValidationException( "TimeoutMinutes must be greater than zero." ); + } + } +} diff --git a/src/Werkr.Core/Werkr.Core.csproj b/src/Werkr.Core/Werkr.Core.csproj new file mode 100644 index 0000000..28221be --- /dev/null +++ b/src/Werkr.Core/Werkr.Core.csproj @@ -0,0 +1,30 @@ + + + + Werkr.Core + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Werkr.Core/Workflows/ConditionEvaluator.cs b/src/Werkr.Core/Workflows/ConditionEvaluator.cs new file mode 100644 index 0000000..a4a285a --- /dev/null +++ b/src/Werkr.Core/Workflows/ConditionEvaluator.cs @@ -0,0 +1,95 @@ +using System.Text.RegularExpressions; + +using Microsoft.Extensions.Logging; + +using Werkr.Data.Entities.Tasks; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Core.Workflows; + +/// +/// Evaluates condition expressions against prior workflow step results. +/// Supports multi-dependency evaluation via (All/Any). +/// +/// Logger instance. +public sealed partial class ConditionEvaluator( + ILogger logger ) { + + /// + /// Evaluates a condition expression against a single prior step's result. + /// + /// The condition expression to evaluate. Null/empty = always true. + /// The prior step's completed job record. + /// true if the condition is satisfied. + public bool Evaluate( string? expression, WerkrJob priorJob ) { + if (string.IsNullOrWhiteSpace( expression )) { + return true; + } + + string trimmed = expression.Trim( ); + + // $? -eq $true / $? -eq $false + Match successMatch = SuccessRegex( ).Match( trimmed ); + if (successMatch.Success) { + bool expected = string.Equals( successMatch.Groups[1].Value, "true", StringComparison.OrdinalIgnoreCase ); + return priorJob.Success == expected; + } + + // $exitCode N + Match exitCodeMatch = ExitCodeRegex( ).Match( trimmed ); + if (exitCodeMatch.Success) { + string op = exitCodeMatch.Groups[1].Value; + int value = int.Parse( exitCodeMatch.Groups[2].Value ); + int actual = priorJob.ExitCode ?? 0; + + return op switch { + "==" => actual == value, + "!=" => actual != value, + ">" => actual > value, + "<" => actual < value, + ">=" => actual >= value, + "<=" => actual <= value, + _ => false, + }; + } + + // Unknown expression — fail-safe + logger.LogWarning( "Unknown condition expression: '{Expression}'. Returning false.", trimmed ); + return false; + } + + /// + /// Evaluates a condition expression against multiple predecessor jobs, + /// applying the step's (All or Any). + /// + /// The condition expression. Null/empty = always true. + /// The predecessor step job results. + /// How to aggregate per-dependency results. + /// true if the aggregated condition is satisfied. + public bool EvaluateMultiple( + string? expression, + IReadOnlyList predecessorJobs, + DependencyMode dependencyMode ) { + + if (string.IsNullOrWhiteSpace( expression )) { + return true; + } + + if (predecessorJobs.Count == 0) { + // No predecessors — default: expression against default values + return Evaluate( expression, new WerkrJob { Success = true, ExitCode = 0 } ); + } + + return dependencyMode switch { + DependencyMode.All => predecessorJobs.All( job => Evaluate( expression, job ) ), + DependencyMode.Any => predecessorJobs.Any( job => Evaluate( expression, job ) ), + _ => predecessorJobs.All( job => Evaluate( expression, job ) ), + }; + } + + [GeneratedRegex( @"^\$\?\s*-eq\s*\$(true|false)$", RegexOptions.IgnoreCase )] + private static partial Regex SuccessRegex( ); + + [GeneratedRegex( @"^\$exitCode\s*(==|!=|>|<|>=|<=)\s*(-?\d+)$", RegexOptions.IgnoreCase )] + private static partial Regex ExitCodeRegex( ); +} diff --git a/src/Werkr.Core/Workflows/WorkflowExecutor.cs b/src/Werkr.Core/Workflows/WorkflowExecutor.cs new file mode 100644 index 0000000..5277d0e --- /dev/null +++ b/src/Werkr.Core/Workflows/WorkflowExecutor.cs @@ -0,0 +1,431 @@ +using System.Threading.Channels; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Werkr.Common.Models; +using Werkr.Core.Tasks; +using Werkr.Data; +using Werkr.Data.Entities.Registration; +using Werkr.Data.Entities.Tasks; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Core.Workflows; + +/// +/// Executes a workflow as a DAG with topological ordering, conditional control flow +/// (If/Else/ElseIf/While/Do), per-step agent resolution, and parallel execution +/// of independent steps within the same topological level. +/// +/// Database context. +/// Workflow service for DAG validation and level retrieval. +/// Job execution service for dispatching tasks. +/// Agent resolver for tag-based agent matching. +/// Condition evaluator for control flow expressions. +/// Tracker for publishing real-time step status updates. +/// Logger instance. +public sealed class WorkflowExecutor( + WerkrDbContext dbContext, + WorkflowService workflowService, + JobExecutionService jobExecutionService, + AgentResolver agentResolver, + ConditionEvaluator conditionEvaluator, + WorkflowRunTracker runTracker, + ILogger logger ) { + + /// + /// Executes a workflow, resolving the appropriate agent for each step + /// based on task TargetTags. Supports multi-agent workflows where + /// different steps may execute on different agents. Independent steps + /// at the same topological level execute in parallel. + /// + /// The workflow to execute. + /// Cancellation token. + /// The completed record. + public async Task ExecuteAsync( Workflow workflow, CancellationToken ct = default ) { + // Create workflow run first so cancellation can be recorded + WorkflowRun run = new( ) { + WorkflowId = workflow.Id, + StartTime = DateTime.UtcNow, + Status = WorkflowRunStatus.Running, + }; + _ = dbContext.WorkflowRuns.Add( run ); + _ = await dbContext.SaveChangesAsync( CancellationToken.None ); + + // Start real-time tracking channel for this run + ChannelWriter writer = runTracker.StartTracking( run.Id ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Starting workflow run {RunId} for workflow {WorkflowId} '{WorkflowName}'.", + run.Id.ToString( ), workflow.Id.ToString( ), workflow.Name ); + } + + // Step result map: stepId → completed job + Dictionary stepResults = []; + + // If/Else/ElseIf chain tracking: stepId → whether that branch was taken + Dictionary branchTaken = []; + + try { + // Validate DAG (throws if cycle or control flow error) + _ = await workflowService.ValidateDagAsync( workflow.Id, ct ); + + // Get topological levels for execution + // NOTE: Steps within the same level are independent and could be parallelized + // with an IDbContextFactory pattern. Currently executed + // sequentially because DbContext is not thread-safe. + IReadOnlyList> levels = + await workflowService.GetTopologicalLevelsAsync( workflow.Id, ct ); + + foreach (IReadOnlyList level in levels) { + ct.ThrowIfCancellationRequested( ); + + // Partition steps into chain-bound (If/Else/ElseIf sequences) and parallelizable + List parallelizable = []; + List chainBound = []; + + foreach (WorkflowStep step in level) { + if (step.ControlStatement is ControlStatement.If or + ControlStatement.Else or ControlStatement.ElseIf) { + chainBound.Add( step ); + } else { + parallelizable.Add( step ); + } + } + + // Execute parallelizable steps sequentially within the level. + // DbContext is not thread-safe, so true parallelism requires + // IDbContextFactory (tracked for future enhancement). + foreach (WorkflowStep step in parallelizable) { + ct.ThrowIfCancellationRequested( ); + + StepExecutionResult result = await ExecuteStepAsync( + step, run, stepResults, branchTaken, writer, ct ); + + if (result.Job is not null) { + stepResults[result.StepId] = result.Job; + } + + if (result.Failed) { + run.Status = WorkflowRunStatus.Failed; + run.EndTime = DateTime.UtcNow; + _ = await dbContext.SaveChangesAsync( CancellationToken.None ); + logger.LogWarning( "Workflow run {RunId} failed at step {StepId}: {Error}.", + run.Id.ToString( ), result.StepId.ToString( ), result.ErrorMessage ); + runTracker.CompleteTracking( run.Id ); + return run; + } + } + + // Execute chain-bound steps sequentially (order matters for If/Else evaluation) + foreach (WorkflowStep step in chainBound) { + ct.ThrowIfCancellationRequested( ); + + StepExecutionResult result = await ExecuteStepAsync( + step, run, stepResults, branchTaken, writer, ct ); + + if (result.Job is not null) { + stepResults[result.StepId] = result.Job; + } + + if (result.Failed) { + run.Status = WorkflowRunStatus.Failed; + run.EndTime = DateTime.UtcNow; + _ = await dbContext.SaveChangesAsync( CancellationToken.None ); + logger.LogWarning( "Workflow run {RunId} failed at step {StepId}: {Error}.", + run.Id.ToString( ), result.StepId.ToString( ), result.ErrorMessage ); + runTracker.CompleteTracking( run.Id ); + return run; + } + } + } + + // All steps completed + run.Status = WorkflowRunStatus.Completed; + run.EndTime = DateTime.UtcNow; + _ = await dbContext.SaveChangesAsync( CancellationToken.None ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Workflow run {RunId} completed successfully.", + run.Id.ToString( ) ); + } + } catch (OperationCanceledException) { + run.Status = WorkflowRunStatus.Cancelled; + run.EndTime = DateTime.UtcNow; + _ = await dbContext.SaveChangesAsync( CancellationToken.None ); + logger.LogWarning( "Workflow run {RunId} was cancelled.", run.Id.ToString( ) ); + } catch (Exception ex) { + run.Status = WorkflowRunStatus.Failed; + run.EndTime = DateTime.UtcNow; + _ = await dbContext.SaveChangesAsync( CancellationToken.None ); + logger.LogError( ex, "Workflow run {RunId} failed with unexpected error.", run.Id.ToString( ) ); + } finally { + runTracker.CompleteTracking( run.Id ); + } + + return run; + } + + /// + /// Retrieves workflow runs for a workflow. + /// + public async Task> GetRunsAsync( + long workflowId, int limit = 50, CancellationToken ct = default ) => + await dbContext.WorkflowRuns.AsNoTracking( ) + .Where( r => r.WorkflowId == workflowId ) + .OrderByDescending( r => r.StartTime ) + .Take( limit ) + .ToListAsync( ct ); + + /// + /// Retrieves a single workflow run with its jobs. + /// + public async Task GetRunAsync( Guid runId, CancellationToken ct = default ) => + await dbContext.WorkflowRuns.AsNoTracking( ) + .Include( r => r.Jobs ) + .FirstOrDefaultAsync( r => r.Id == runId, ct ); + + /// Executes a single workflow step, handling control flow. + private async Task ExecuteStepAsync( + WorkflowStep step, + WorkflowRun run, + Dictionary stepResults, + Dictionary branchTaken, + ChannelWriter writer, + CancellationToken ct ) { + + // Build a display name for status updates (WorkflowStep has no Name property) + string stepLabel = $"Step {step.Order} (#{step.Id})"; + + // Publish "Running" status + await writer.WriteAsync( new WorkflowStepStatusUpdate( + run.Id, step.Id, stepLabel, "Running", DateTime.UtcNow, null ), ct ); + + // Load task if not eagerly loaded + WerkrTask? task = step.Task; + if (task is null) { + task = await dbContext.Tasks.AsNoTracking( ).FirstOrDefaultAsync( t => t.Id == step.TaskId, ct ); + if (task is null) { + string taskError = $"Task with Id={step.TaskId} not found for step {step.Id}."; + await writer.WriteAsync( new WorkflowStepStatusUpdate( + run.Id, step.Id, stepLabel, "Failed", DateTime.UtcNow, taskError ), ct ); + return StepExecutionResult.Fail( step.Id, taskError ); + } + } + + // Refine step label now that we have the task name + stepLabel = $"Step {step.Order}: {task.Name}"; + + // Gather predecessor jobs + List predecessorJobs = []; + foreach (WorkflowStepDependency dep in step.Dependencies) { + if (stepResults.TryGetValue( dep.DependsOnStepId, out WerkrJob? predJob )) { + predecessorJobs.Add( predJob ); + } + } + + // Check dependency satisfaction + if (!CheckDependencies( step, stepResults )) { + string depError = $"Dependencies not satisfied for step {step.Id} (DependencyMode={step.DependencyMode})."; + await writer.WriteAsync( new WorkflowStepStatusUpdate( + run.Id, step.Id, stepLabel, "Failed", DateTime.UtcNow, depError ), ct ); + return StepExecutionResult.Fail( step.Id, depError ); + } + + // Evaluate control flow + bool shouldExecute = EvaluateControlFlow( + step, predecessorJobs, branchTaken ); + + if (!shouldExecute) { + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Step {StepId} skipped by control flow ({ControlStatement}).", + step.Id.ToString( ), step.ControlStatement.ToString( ) ); + } + await writer.WriteAsync( new WorkflowStepStatusUpdate( + run.Id, step.Id, stepLabel, "Skipped", DateTime.UtcNow, null ), ct ); + return StepExecutionResult.Skipped( step.Id ); + } + + // Resolve agent for this step + RegisteredConnection? agent = await ResolveAgentForStepAsync( step, task, ct ); + if (agent is null) { + string agentError = $"No agent available for step {step.Id} (task '{task.Name}', tags=[{string.Join( ", ", task.TargetTags )}])."; + await writer.WriteAsync( new WorkflowStepStatusUpdate( + run.Id, step.Id, stepLabel, "Failed", DateTime.UtcNow, agentError ), ct ); + return StepExecutionResult.Fail( step.Id, agentError ); + } + + // Handle While/Do loops + if (step.ControlStatement is ControlStatement.While or ControlStatement.Do) { + return await ExecuteLoopStepAsync( step, task, agent, run, predecessorJobs, ct ); + } + + // Execute the step's task + WerkrJob job = await jobExecutionService.ExecuteOnAgentAsync( task, agent, run.Id, ct ); + + // Record branch taken for If/ElseIf chains + if (step.ControlStatement is ControlStatement.If or ControlStatement.ElseIf) { + branchTaken[step.Id] = true; + } + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Step {StepId} executed: Success={Success}, JobId={JobId}.", + step.Id.ToString( ), job.Success.ToString( ), job.Id.ToString( ) ); + } + + string completionStatus = job.Success ? "Completed" : "Failed"; + await writer.WriteAsync( new WorkflowStepStatusUpdate( + run.Id, step.Id, stepLabel, completionStatus, DateTime.UtcNow, + job.Success ? null : "Job execution failed" ), ct ); + + return StepExecutionResult.Ok( step.Id, job ); + } + + /// Executes a While or Do loop step. + private async Task ExecuteLoopStepAsync( + WorkflowStep step, + WerkrTask task, + RegisteredConnection agent, + WorkflowRun run, + List predecessorJobs, + CancellationToken ct + ) { + + WerkrJob? lastJob = null; + int iterations = 0; + bool isDoLoop = step.ControlStatement == ControlStatement.Do; + + while (iterations < step.MaxIterations) { + ct.ThrowIfCancellationRequested( ); + + // For While: check condition before execution + // For Do: execute first, then check condition + if (!isDoLoop || iterations > 0) { + IReadOnlyList evalJobs = lastJob is not null ? [lastJob] : predecessorJobs; + bool conditionMet = conditionEvaluator.EvaluateMultiple( + step.ConditionExpression, evalJobs, step.DependencyMode ); + if (!conditionMet) { + break; + } + } + + lastJob = await jobExecutionService.ExecuteOnAgentAsync( task, agent, run.Id, ct ); + iterations++; + + if (!lastJob.Success) { + break; + } + } + + if (iterations >= step.MaxIterations) { + logger.LogWarning( "Step {StepId} reached MaxIterations ({Max}).", + step.Id.ToString( ), step.MaxIterations.ToString( ) ); + } + + return lastJob is not null + ? StepExecutionResult.Ok( step.Id, lastJob ) + : StepExecutionResult.Skipped( step.Id ); + } + + /// Checks whether dependencies are satisfied based on DependencyMode. + private static bool CheckDependencies( + WorkflowStep step, + Dictionary stepResults ) { + + if (step.Dependencies.Count == 0) { + return true; // Root step — no dependencies + } + + return step.DependencyMode switch { + DependencyMode.All => + // All predecessors must have a result (they were executed, not necessarily succeeded) + step.Dependencies.All( d => stepResults.ContainsKey( d.DependsOnStepId ) ), + DependencyMode.Any => + // At least one predecessor has a result + step.Dependencies.Any( d => stepResults.ContainsKey( d.DependsOnStepId ) ), + _ => step.Dependencies.All( d => stepResults.ContainsKey( d.DependsOnStepId ) ), + }; + } + + /// Evaluates control flow to determine if a step should execute. + private bool EvaluateControlFlow( + WorkflowStep step, + List predecessorJobs, + Dictionary branchTaken ) { + + switch (step.ControlStatement) { + case ControlStatement.Sequential: + return true; + + case ControlStatement.If: + bool ifResult = conditionEvaluator.EvaluateMultiple( + step.ConditionExpression, predecessorJobs, step.DependencyMode ); + branchTaken[step.Id] = ifResult; + return ifResult; + + case ControlStatement.ElseIf: { + // Check if any prior If/ElseIf in the chain was taken + bool priorTaken = step.Dependencies.Any( d => + branchTaken.TryGetValue( d.DependsOnStepId, out bool taken ) && taken ); + if (priorTaken) { + branchTaken[step.Id] = false; + return false; + } + bool elseIfResult = conditionEvaluator.EvaluateMultiple( + step.ConditionExpression, predecessorJobs, step.DependencyMode ); + branchTaken[step.Id] = elseIfResult; + return elseIfResult; + } + + case ControlStatement.Else: { + // Execute only if no prior If/ElseIf in the chain was taken + bool anyPriorTaken = step.Dependencies.Any( d => + branchTaken.TryGetValue( d.DependsOnStepId, out bool taken ) && taken ); + return !anyPriorTaken; + } + + case ControlStatement.While: + case ControlStatement.Do: + // Loop execution is handled by ExecuteLoopStepAsync + return true; + + default: + return true; + } + } + + /// Resolves the agent for a workflow step. + private async Task ResolveAgentForStepAsync( + WorkflowStep step, WerkrTask task, CancellationToken ct ) { + + if (step.AgentConnectionIdOverride.HasValue) { + RegisteredConnection? overrideAgent = await dbContext.RegisteredConnections + .FirstOrDefaultAsync( c => c.Id == step.AgentConnectionIdOverride.Value, ct ); + if (overrideAgent is null) { + logger.LogWarning( "AgentConnectionIdOverride {AgentId} not found for step {StepId}.", + step.AgentConnectionIdOverride.Value.ToString( ), step.Id.ToString( ) ); + } + return overrideAgent; + } + + return await agentResolver.ResolveAsync( task.TargetTags, ct ); + } + + /// Result of executing a single workflow step. + private sealed record StepExecutionResult( + long StepId, + WerkrJob? Job, + bool Failed, + bool WasSkipped, + string? ErrorMessage ) { + + public static StepExecutionResult Ok( long stepId, WerkrJob job ) => + new( stepId, job, Failed: false, WasSkipped: false, ErrorMessage: null ); + + public static StepExecutionResult Skipped( long stepId ) => + new( stepId, Job: null, Failed: false, WasSkipped: true, ErrorMessage: null ); + + public static StepExecutionResult Fail( long stepId, string errorMessage ) => + new( stepId, Job: null, Failed: true, WasSkipped: false, ErrorMessage: errorMessage ); + } +} diff --git a/src/Werkr.Core/Workflows/WorkflowRunTracker.cs b/src/Werkr.Core/Workflows/WorkflowRunTracker.cs new file mode 100644 index 0000000..ec80cf6 --- /dev/null +++ b/src/Werkr.Core/Workflows/WorkflowRunTracker.cs @@ -0,0 +1,49 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; + +using Werkr.Common.Models; + +namespace Werkr.Core.Workflows; + +/// +/// Manages instances per workflow run so that +/// the can publish real-time step updates +/// and UI consumers can stream them via . +/// Registered as a singleton. +/// +public sealed class WorkflowRunTracker { + + private readonly ConcurrentDictionary> _channels = new( ); + + /// + /// Creates and returns a for the given run. + /// Called by when a run begins. + /// + public ChannelWriter StartTracking( Guid runId ) { + Channel channel = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleWriter = true } ); + _ = _channels.TryAdd( runId, channel ); + return channel.Writer; + } + + /// + /// Completes the channel for a run and removes it from tracking. + /// Called by when the run ends. + /// + public void CompleteTracking( Guid runId ) { + if (_channels.TryRemove( runId, out Channel? channel )) { + _ = channel.Writer.TryComplete( ); + } + } + + /// + /// Returns an that streams step updates + /// for the given run. Returns null if the run is not being tracked. + /// + public IAsyncEnumerable? GetUpdates( Guid runId ) { + return _channels.TryGetValue( runId, out Channel? channel ) ? channel.Reader.ReadAllAsync( ) : null; + } + + /// Returns true if the run is currently being tracked. + public bool IsTracking( Guid runId ) => _channels.ContainsKey( runId ); +} diff --git a/src/Werkr.Core/Workflows/WorkflowService.cs b/src/Werkr.Core/Workflows/WorkflowService.cs new file mode 100644 index 0000000..666aa9e --- /dev/null +++ b/src/Werkr.Core/Workflows/WorkflowService.cs @@ -0,0 +1,422 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using Werkr.Data; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Core.Workflows; + +/// +/// Provides CRUD operations for entities including +/// step and dependency management, and DAG validation via Kahn's algorithm. +/// +/// Database context. +/// Logger instance. +public sealed class WorkflowService( WerkrDbContext dbContext, ILogger logger ) { + + /// Creates a new workflow. + /// The workflow to create. + /// Cancellation token. + /// The created workflow with generated Id. + /// Workflow name is not unique. + public async Task CreateAsync( Workflow workflow, CancellationToken ct = default ) { + ValidateWorkflow( workflow ); + + bool nameExists = await dbContext.Workflows.AnyAsync( w => w.Name == workflow.Name, ct ); + if (nameExists) { + throw new InvalidOperationException( $"A workflow with name '{workflow.Name}' already exists." ); + } + + _ = dbContext.Workflows.Add( workflow ); + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Created workflow {WorkflowId} '{WorkflowName}'.", + workflow.Id.ToString( ), workflow.Name ); + } + + return workflow; + } + + /// Updates an existing workflow. + /// The workflow with updated values. + /// Cancellation token. + /// The updated workflow. + /// Workflow not found. + /// Workflow name is not unique. + public async Task UpdateAsync( Workflow workflow, CancellationToken ct = default ) { + ValidateWorkflow( workflow ); + + Workflow existing = await dbContext.Workflows.FirstOrDefaultAsync( w => w.Id == workflow.Id, ct ) + ?? throw new KeyNotFoundException( $"Workflow with Id={workflow.Id} was not found." ); + + bool nameConflict = await dbContext.Workflows.AnyAsync( + w => w.Name == workflow.Name && w.Id != workflow.Id, ct ); + if (nameConflict) { + throw new InvalidOperationException( $"A workflow with name '{workflow.Name}' already exists." ); + } + + existing.Name = workflow.Name; + existing.Description = workflow.Description; + existing.Enabled = workflow.Enabled; + existing.ScheduleId = workflow.ScheduleId; + + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Updated workflow {WorkflowId} '{WorkflowName}'.", + existing.Id.ToString( ), existing.Name ); + } + + return existing; + } + + /// Deletes a workflow by ID. + /// The workflow identifier. + /// Cancellation token. + /// Workflow not found. + public async Task DeleteAsync( long workflowId, CancellationToken ct = default ) { + Workflow existing = await dbContext.Workflows.FirstOrDefaultAsync( w => w.Id == workflowId, ct ) + ?? throw new KeyNotFoundException( $"Workflow with Id={workflowId} was not found." ); + + _ = dbContext.Workflows.Remove( existing ); + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Deleted workflow {WorkflowId} '{WorkflowName}'.", + workflowId.ToString( ), existing.Name ); + } + } + + /// Retrieves a single workflow by ID with steps, dependencies, and tasks. + /// The workflow identifier. + /// Cancellation token. + /// The workflow, or null if not found. + public async Task GetByIdAsync( long workflowId, CancellationToken ct = default ) => + await dbContext.Workflows + .Include( w => w.Steps ) + .ThenInclude( s => s.Dependencies ) + .Include( w => w.Steps ) + .ThenInclude( s => s.Task ) + .Include( w => w.Schedule ) + .AsNoTracking( ) + .FirstOrDefaultAsync( w => w.Id == workflowId, ct ); + + /// Retrieves all workflows with steps. + /// Cancellation token. + /// A read-only list of workflows. + public async Task> GetAllAsync( CancellationToken ct = default ) => + await dbContext.Workflows + .Include( w => w.Steps ) + .ThenInclude( s => s.Dependencies ) + .Include( w => w.Steps ) + .ThenInclude( s => s.Task ) + .Include( w => w.Schedule ) + .AsNoTracking( ) + .OrderBy( w => w.Name ) + .ToListAsync( ct ); + + /// Adds a step to a workflow. + /// The workflow to add the step to. + /// The step to add. + /// Cancellation token. + /// Workflow not found. + public async Task AddStepAsync( long workflowId, WorkflowStep step, CancellationToken ct = default ) { + bool exists = await dbContext.Workflows.AnyAsync( w => w.Id == workflowId, ct ); + if (!exists) { + throw new KeyNotFoundException( $"Workflow with Id={workflowId} was not found." ); + } + + step.WorkflowId = workflowId; + _ = dbContext.WorkflowSteps.Add( step ); + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Added step {StepId} to workflow {WorkflowId}.", + step.Id.ToString( ), workflowId.ToString( ) ); + } + + return step; + } + + /// Removes a step from a workflow. + /// The step identifier. + /// Cancellation token. + /// Step not found. + public async Task RemoveStepAsync( long stepId, CancellationToken ct = default ) { + WorkflowStep step = await dbContext.WorkflowSteps.FirstOrDefaultAsync( s => s.Id == stepId, ct ) + ?? throw new KeyNotFoundException( $"WorkflowStep with Id={stepId} was not found." ); + + _ = dbContext.WorkflowSteps.Remove( step ); + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Removed step {StepId} from workflow {WorkflowId}.", + stepId.ToString( ), step.WorkflowId.ToString( ) ); + } + } + + /// Updates an existing workflow step. + /// The step with updated values. + /// Cancellation token. + /// Step not found. + public async Task UpdateStepAsync( WorkflowStep step, CancellationToken ct = default ) { + WorkflowStep existing = await dbContext.WorkflowSteps.FirstOrDefaultAsync( s => s.Id == step.Id, ct ) + ?? throw new KeyNotFoundException( $"WorkflowStep with Id={step.Id} was not found." ); + + existing.TaskId = step.TaskId; + existing.Order = step.Order; + existing.ControlStatement = step.ControlStatement; + existing.ConditionExpression = step.ConditionExpression; + existing.MaxIterations = step.MaxIterations; + existing.AgentConnectionIdOverride = step.AgentConnectionIdOverride; + existing.DependencyMode = step.DependencyMode; + + _ = await dbContext.SaveChangesAsync( ct ); + return existing; + } + + /// Adds a dependency relationship between two steps. + /// The dependent step. + /// The predecessor step. + /// Cancellation token. + /// Step not found. + /// Dependency already exists or self-reference. + public async Task AddStepDependencyAsync( long stepId, long dependsOnStepId, CancellationToken ct = default ) { + if (stepId == dependsOnStepId) { + throw new InvalidOperationException( "A step cannot depend on itself." ); + } + + bool stepExists = await dbContext.WorkflowSteps.AnyAsync( s => s.Id == stepId, ct ); + if (!stepExists) { + throw new KeyNotFoundException( $"WorkflowStep with Id={stepId} was not found." ); + } + + bool depExists = await dbContext.WorkflowSteps.AnyAsync( s => s.Id == dependsOnStepId, ct ); + if (!depExists) { + throw new KeyNotFoundException( $"WorkflowStep with Id={dependsOnStepId} was not found." ); + } + + bool alreadyExists = await dbContext.WorkflowStepDependencies + .AnyAsync( d => d.StepId == stepId && d.DependsOnStepId == dependsOnStepId, ct ); + if (alreadyExists) { + throw new InvalidOperationException( + $"Dependency from step {stepId} on step {dependsOnStepId} already exists." ); + } + + WorkflowStepDependency dep = new( ) { + StepId = stepId, + DependsOnStepId = dependsOnStepId, + }; + _ = dbContext.WorkflowStepDependencies.Add( dep ); + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Added dependency: step {StepId} depends on step {DepStepId}.", + stepId.ToString( ), dependsOnStepId.ToString( ) ); + } + } + + /// Removes a dependency relationship between two steps. + /// The dependent step. + /// The predecessor step. + /// Cancellation token. + /// Dependency not found. + public async Task RemoveStepDependencyAsync( long stepId, long dependsOnStepId, CancellationToken ct = default ) { + WorkflowStepDependency dep = await dbContext.WorkflowStepDependencies + .FirstOrDefaultAsync( d => d.StepId == stepId && d.DependsOnStepId == dependsOnStepId, ct ) + ?? throw new KeyNotFoundException( + $"Dependency from step {stepId} on step {dependsOnStepId} was not found." ); + + _ = dbContext.WorkflowStepDependencies.Remove( dep ); + _ = await dbContext.SaveChangesAsync( ct ); + } + + /// + /// Validates the workflow DAG: checks for cycles via Kahn's algorithm + /// and validates control flow constraints. + /// + /// The workflow identifier. + /// Cancellation token. + /// Topologically sorted steps, ties broken by . + /// Workflow not found. + /// Cycle detected or control flow validation failed. + public async Task> ValidateDagAsync( long workflowId, CancellationToken ct = default ) { + List steps = await dbContext.WorkflowSteps + .Include( s => s.Dependencies ) + .Where( s => s.WorkflowId == workflowId ) + .ToListAsync( ct ); + + if (steps.Count == 0) { + bool workflowExists = await dbContext.Workflows.AnyAsync( w => w.Id == workflowId, ct ); + return !workflowExists ? throw new KeyNotFoundException( $"Workflow with Id={workflowId} was not found." ) : []; + } + + // Build adjacency list and in-degree map + Dictionary> adjacency = []; + Dictionary inDegree = []; + Dictionary stepMap = []; + + foreach (WorkflowStep step in steps) { + stepMap[step.Id] = step; + adjacency[step.Id] = []; + inDegree[step.Id] = 0; + } + + foreach (WorkflowStep step in steps) { + foreach (WorkflowStepDependency dep in step.Dependencies) { + if (adjacency.ContainsKey( dep.DependsOnStepId )) { + adjacency[dep.DependsOnStepId].Add( step.Id ); + inDegree[step.Id]++; + } + } + } + + // Kahn's algorithm — use a priority queue for Order tiebreaking + PriorityQueue queue = new( ); + foreach (KeyValuePair kvp in inDegree) { + if (kvp.Value == 0) { + queue.Enqueue( kvp.Key, stepMap[kvp.Key].Order ); + } + } + + List sorted = []; + while (queue.Count > 0) { + long current = queue.Dequeue( ); + sorted.Add( stepMap[current] ); + + foreach (long dependent in adjacency[current]) { + inDegree[dependent]--; + if (inDegree[dependent] == 0) { + queue.Enqueue( dependent, stepMap[dependent].Order ); + } + } + } + + if (sorted.Count != steps.Count) { + throw new InvalidOperationException( + $"Cycle detected in workflow {workflowId}: processed {sorted.Count} of {steps.Count} steps." ); + } + + // Validate control flow constraints + ValidateControlFlow( sorted ); + + return sorted; + } + + /// + /// Gets the topological levels for parallel execution. + /// Steps at the same level have all dependencies satisfied and can run concurrently. + /// + /// The workflow identifier. + /// Cancellation token. + /// Ordered list of levels, each containing steps that can execute in parallel. + public async Task>> GetTopologicalLevelsAsync( + long workflowId, CancellationToken ct = default ) { + + List steps = await dbContext.WorkflowSteps + .Include( s => s.Dependencies ) + .Include( s => s.Task ) + .Where( s => s.WorkflowId == workflowId ) + .ToListAsync( ct ); + + if (steps.Count == 0) { + return []; + } + + Dictionary stepMap = steps.ToDictionary( s => s.Id ); + Dictionary inDegree = steps.ToDictionary( s => s.Id, _ => 0 ); + Dictionary> adjacency = steps.ToDictionary( s => s.Id, _ => new List( ) ); + + foreach (WorkflowStep step in steps) { + foreach (WorkflowStepDependency dep in step.Dependencies) { + if (adjacency.ContainsKey( dep.DependsOnStepId )) { + adjacency[dep.DependsOnStepId].Add( step.Id ); + inDegree[step.Id]++; + } + } + } + + List> levels = []; + List currentLevel = [.. inDegree.Where( kvp => kvp.Value == 0 ).Select( kvp => kvp.Key )]; + + while (currentLevel.Count > 0) { + // Sort within level by Order for determinism + List levelSteps = [.. currentLevel + .Select( id => stepMap[id] ) + .OrderBy( s => s.Order )]; + levels.Add( levelSteps ); + + List nextLevel = []; + foreach (long id in currentLevel) { + foreach (long dependent in adjacency[id]) { + inDegree[dependent]--; + if (inDegree[dependent] == 0) { + nextLevel.Add( dependent ); + } + } + } + currentLevel = nextLevel; + } + + int totalProcessed = levels.Sum( l => l.Count ); + return totalProcessed != steps.Count + ? throw new InvalidOperationException( + $"Cycle detected in workflow {workflowId}: processed {totalProcessed} of {steps.Count} steps." ) + : (IReadOnlyList>)levels; + } + + /// Validates control flow constraints on a topologically sorted step list. + private static void ValidateControlFlow( List sorted ) { + HashSet processedIds = []; + + foreach (WorkflowStep step in sorted) { + switch (step.ControlStatement) { + case ControlStatement.If: + case ControlStatement.ElseIf: + case ControlStatement.While: + if (string.IsNullOrWhiteSpace( step.ConditionExpression )) { + throw new InvalidOperationException( + $"Step {step.Id} ({step.ControlStatement}) must have a ConditionExpression." ); + } + break; + } + + switch (step.ControlStatement) { + case ControlStatement.Else: + case ControlStatement.ElseIf: { + // Must depend on an If or ElseIf step + bool hasIfDep = step.Dependencies.Any( d => + processedIds.Contains( d.DependsOnStepId ) && + sorted.Any( s => + s.Id == d.DependsOnStepId && + s.ControlStatement is ControlStatement.If or ControlStatement.ElseIf ) ); + if (!hasIfDep) { + throw new InvalidOperationException( + $"Step {step.Id} ({step.ControlStatement}) must depend on an If or ElseIf step." ); + } + break; + } + } + + switch (step.ControlStatement) { + case ControlStatement.While: + case ControlStatement.Do: + if (step.MaxIterations <= 0) { + throw new InvalidOperationException( + $"Step {step.Id} ({step.ControlStatement}) must have MaxIterations > 0." ); + } + break; + } + + _ = processedIds.Add( step.Id ); + } + } + + /// Validates basic workflow properties. + private static void ValidateWorkflow( Workflow workflow ) { + if (string.IsNullOrWhiteSpace( workflow.Name )) { + throw new System.ComponentModel.DataAnnotations.ValidationException( "Workflow name is required." ); + } + } +} diff --git a/src/Werkr.Core/packages.lock.json b/src/Werkr.Core/packages.lock.json new file mode 100644 index 0000000..ee9b828 --- /dev/null +++ b/src/Werkr.Core/packages.lock.json @@ -0,0 +1,428 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Grpc.Net.Client": { + "type": "Direct", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.78.0, )", + "resolved": "2.78.0", + "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==", + "dependencies": { + "Grpc.Core.Api": "2.76.0" + } + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.AspNetCore.Authorization": "[10.0.3, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Microsoft.AspNetCore.Authorization": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + } + } + } +} \ No newline at end of file diff --git a/src/Werkr.Data.Identity/Authorization/PermissionAuthorizationHandler.cs b/src/Werkr.Data.Identity/Authorization/PermissionAuthorizationHandler.cs new file mode 100644 index 0000000..4adcb8a --- /dev/null +++ b/src/Werkr.Data.Identity/Authorization/PermissionAuthorizationHandler.cs @@ -0,0 +1,31 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Werkr.Common.Auth; +using Werkr.Data.Identity.Services; + +namespace Werkr.Data.Identity.Authorization; + +/// +/// Evaluates by checking the user's roles +/// against the role-permission mapping via . +/// +public sealed class PermissionAuthorizationHandler( IPermissionService permissionService ) + : AuthorizationHandler { + + /// + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionRequirement requirement ) { + if (context.User.Identity?.IsAuthenticated != true) { + return; + } + + IEnumerable roles = context.User.FindAll( ClaimTypes.Role ).Select( c => c.Value ); + + bool hasPermission = await permissionService.HasPermissionAsync( roles, requirement.Permission ); + + if (hasPermission) { + context.Succeed( requirement ); + } + } +} diff --git a/src/Werkr.Data.Identity/Entities/ApiKey.cs b/src/Werkr.Data.Identity/Entities/ApiKey.cs new file mode 100644 index 0000000..8d274ab --- /dev/null +++ b/src/Werkr.Data.Identity/Entities/ApiKey.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; + +namespace Werkr.Data.Identity.Entities; + +/// +/// Represents an API key that can be exchanged for a short-lived JWT bearer token. +/// API keys inherit the role of their creator at creation time. +/// +public class ApiKey { + /// Primary key. + public Guid Id { get; set; } + + /// + /// The hashed API key value (SHA-256). + /// The raw key is only returned once at creation time. + /// + [Required] + [MaxLength( 128 )] + public string KeyHash { get; set; } = string.Empty; + + /// + /// A short prefix of the key for identification (e.g., first 8 chars). + /// Stored in plain text for display purposes. + /// + [Required] + [MaxLength( 16 )] + public string KeyPrefix { get; set; } = string.Empty; + + /// Human-readable name for the API key. + [Required] + [MaxLength( 200 )] + public string Name { get; set; } = string.Empty; + + /// The role inherited from the creator at key creation time. + [Required] + [MaxLength( 64 )] + public string Role { get; set; } = string.Empty; + + /// The user ID of the creator (FK to users table). + [Required] + public string CreatedByUserId { get; set; } = string.Empty; + + /// Navigation property to the creator. + public WerkrUser? CreatedByUser { get; set; } + + /// When the API key was created (UTC). + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + + /// + /// Optional expiration date (UTC). Null means the key does not expire. + /// + public DateTime? ExpiresUtc { get; set; } + + /// Whether the API key has been revoked. + public bool IsRevoked { get; set; } + + /// When the key was last used to obtain a token (UTC). + public DateTime? LastUsedUtc { get; set; } +} diff --git a/src/Werkr.Data.Identity/Entities/ConfigurationSettings.cs b/src/Werkr.Data.Identity/Entities/ConfigurationSettings.cs new file mode 100644 index 0000000..9d2ab48 --- /dev/null +++ b/src/Werkr.Data.Identity/Entities/ConfigurationSettings.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Entities; +using Werkr.Data.Entities.Interfaces; + +namespace Werkr.Data.Identity.Entities; + +/// +/// Global application configuration stored in the identity database. +/// Exactly one row exists; seeded on first startup. +/// +[Table( "config_settings" )] +public class ConfigurationSettings : ConcurrencyBase, IKey { + /// Unique identifier. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public Guid Id { get; set; } + + /// Default RSA key size in bits. + public int DefaultKeySize { get; set; } = 4096; + + // ── Server identity ────────────────────────────────────────────── + /// Display name shown in the Blazor UI header. + [MaxLength( 200 )] + public string ServerName { get; set; } = "Werkr Server"; + + /// Whether new agent registrations are accepted. + public bool AllowRegistration { get; set; } = true; + + // ── UI polling ─────────────────────────────────────────────────── + /// Seconds between dashboard / list auto-refresh polls. + public int PollingIntervalSeconds { get; set; } = 30; + + /// Seconds between run-detail / workflow-run auto-refresh polls. + public int RunDetailPollingIntervalSeconds { get; set; } = 15; +} diff --git a/src/Werkr.Data.Identity/Entities/RolePermission.cs b/src/Werkr.Data.Identity/Entities/RolePermission.cs new file mode 100644 index 0000000..5e68fa6 --- /dev/null +++ b/src/Werkr.Data.Identity/Entities/RolePermission.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +using Microsoft.AspNetCore.Identity; + +using Werkr.Common.Auth; + +namespace Werkr.Data.Identity.Entities; + +/// +/// Join entity mapping a role to a permission. Enables role-based permission checking +/// and future custom role creation by end administrators. +/// +public class RolePermission { + /// Primary key. + public long Id { get; set; } + + /// The Identity role ID (FK to roles table). + [Required] + public string RoleId { get; set; } = string.Empty; + + /// Navigation property to the Identity role. + public IdentityRole? Role { get; set; } + + /// The permission granted to this role. + [Required] + public Permission Permission { get; set; } +} diff --git a/src/Werkr.Data.Identity/Entities/WerkrUser.cs b/src/Werkr.Data.Identity/Entities/WerkrUser.cs new file mode 100644 index 0000000..fc68c45 --- /dev/null +++ b/src/Werkr.Data.Identity/Entities/WerkrUser.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Identity; + +namespace Werkr.Data.Identity.Entities; + +/// +/// Application user extending ASP.NET Core Identity. +/// +public class WerkrUser : IdentityUser { + /// Display name for the user. + public string Name { get; set; } = string.Empty; + + /// Whether the user account is enabled. + public bool Enabled { get; set; } = true; + + /// Whether the user must change their password on next login. + public bool ChangePassword { get; set; } + + /// Whether the user is required to enroll in 2FA. + public bool Requires2FA { get; set; } + + /// Timestamp of the user's most recent successful login (UTC). + public DateTime? LastLoginUtc { get; set; } +} diff --git a/src/Werkr.Data.Identity/Extensions/IdentityExtensions.cs b/src/Werkr.Data.Identity/Extensions/IdentityExtensions.cs new file mode 100644 index 0000000..4c9c972 --- /dev/null +++ b/src/Werkr.Data.Identity/Extensions/IdentityExtensions.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Werkr.Common; + +using Werkr.Data.Identity.Entities; + +namespace Werkr.Data.Identity.Extensions; + +/// +/// Extension methods for registering Werkr Identity services. +/// +public static class IdentityExtensions { + /// + /// Registers and ASP.NET Core Identity with Werkr defaults. + /// + /// The service collection. + /// The database provider to use. + /// The database connection string. + /// The for further configuration. + public static IdentityBuilder AddWerkrIdentity( + this IServiceCollection services, + DatabaseProvider provider, + string connectionString ) { + // Register provider-specific identity DbContext with forwarding to base type. + // EF Core requires a distinct type per provider so each set of migrations gets + // its own ModelSnapshot — same pattern as WerkrDbContextExtensions. + switch (provider) { + case DatabaseProvider.Postgres: + _ = services.AddDbContext( options => { + _ = options.UseNpgsql( connectionString, npgsql => + npgsql.MigrationsHistoryTable( "__EFMigrationsHistory", "werkr_identity" ) ) + .UseSnakeCaseNamingConvention( ); + } ); + _ = services.AddScoped( sp => + sp.GetRequiredService( ) ); + break; + + case DatabaseProvider.SQLite: + _ = services.AddDbContext( options => { + _ = options.UseSqlite( connectionString ) + .UseSnakeCaseNamingConvention( ); + } ); + _ = services.AddScoped( sp => + sp.GetRequiredService( ) ); + break; + + default: + throw new ArgumentOutOfRangeException( nameof( provider ), provider, "Unsupported database provider." ); + } + + // Configure Identity with NIST-aligned defaults + return services.AddIdentity( options => ConfigureIdentityOptions( options ) ) + .AddEntityFrameworkStores( ) + .AddDefaultTokenProviders( ); + } + + /// + /// Configures Identity options with NIST-aligned password policy. + /// + public static void ConfigureIdentityOptions( IdentityOptions options ) { + // Password policy (NIST-aligned): favor length over composition complexity. + options.Password.RequiredLength = 12; + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequiredUniqueChars = 1; + + // Lockout + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes( 15 ); + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.AllowedForNewUsers = true; + + // User + options.User.RequireUniqueEmail = true; + + // Sign-in + options.SignIn.RequireConfirmedAccount = false; + } +} diff --git a/src/Werkr.Data.Identity/PostgresIdentityDesignTimeFactory.cs b/src/Werkr.Data.Identity/PostgresIdentityDesignTimeFactory.cs new file mode 100644 index 0000000..4534391 --- /dev/null +++ b/src/Werkr.Data.Identity/PostgresIdentityDesignTimeFactory.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Werkr.Data.Identity; + +/// +/// Design-time factory for , +/// used by dotnet ef CLI when --context PostgresWerkrIdentityDbContext is specified. +/// +public class PostgresIdentityDesignTimeFactory : IDesignTimeDbContextFactory { + /// + public PostgresWerkrIdentityDbContext CreateDbContext( string[] args ) { + DbContextOptionsBuilder optionsBuilder = new( ); + _ = optionsBuilder.UseNpgsql( + "Host=localhost;Database=werkr_identity_design;Username=postgres;Password=postgres", + npgsql => npgsql.MigrationsHistoryTable( "__EFMigrationsHistory", "werkr_identity" ) ) + .UseSnakeCaseNamingConvention( ); + return new PostgresWerkrIdentityDbContext( optionsBuilder.Options ); + } +} diff --git a/src/Werkr.Data.Identity/PostgresWerkrIdentityDbContext.cs b/src/Werkr.Data.Identity/PostgresWerkrIdentityDbContext.cs new file mode 100644 index 0000000..0a67e13 --- /dev/null +++ b/src/Werkr.Data.Identity/PostgresWerkrIdentityDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + +namespace Werkr.Data.Identity; + +/// +/// Postgres-specific used for migration generation and runtime resolution. +/// EF Core requires a distinct type per provider so each set of migrations gets its own +/// . +/// +/// Creates a new Postgres-targeted instance. +/// The Postgres-configured options. +public class PostgresWerkrIdentityDbContext( DbContextOptions options ) + : WerkrIdentityDbContext( options ) { } diff --git a/src/Werkr.Data.Identity/Roles/DefaultRoles.cs b/src/Werkr.Data.Identity/Roles/DefaultRoles.cs new file mode 100644 index 0000000..262df55 --- /dev/null +++ b/src/Werkr.Data.Identity/Roles/DefaultRoles.cs @@ -0,0 +1,16 @@ +namespace Werkr.Data.Identity.Roles; + +/// +/// Default roles for the Werkr application. +/// Simplified to 3 roles per plan spec (expandable later). +/// +public enum DefaultRoles { + /// Full system administration access. + Admin = 0, + + /// Can execute tasks and manage workflows. + Operator = 1, + + /// Read-only access. + Viewer = 2, +} diff --git a/src/Werkr.Data.Identity/Services/IPermissionService.cs b/src/Werkr.Data.Identity/Services/IPermissionService.cs new file mode 100644 index 0000000..8809c75 --- /dev/null +++ b/src/Werkr.Data.Identity/Services/IPermissionService.cs @@ -0,0 +1,34 @@ +using Werkr.Common.Auth; + +namespace Werkr.Data.Identity.Services; + +/// +/// Service for checking user permissions against the role-permission mapping. +/// +public interface IPermissionService { + /// + /// Checks whether any of the specified roles has the given permission. + /// + /// The role names to check. + /// The permission to verify. + /// Cancellation token. + /// true if at least one role has the permission; otherwise false. + Task HasPermissionAsync( IEnumerable roles, Permission permission, CancellationToken ct = default ); + + /// + /// Gets all permissions granted to any of the specified roles. + /// + /// The role names to check. + /// Cancellation token. + /// Distinct set of permissions. + Task> GetPermissionsAsync( IEnumerable roles, CancellationToken ct = default ); + + /// + /// Gets the list of permissions granted to a specific role by name. + /// Used by the token issuer to embed permissions in JWT claims. + /// + /// The role name. + /// Cancellation token. + /// List of granted permissions. + Task> GetPermissionsForRoleAsync( string roleName, CancellationToken ct = default ); +} diff --git a/src/Werkr.Data.Identity/Services/PermissionService.cs b/src/Werkr.Data.Identity/Services/PermissionService.cs new file mode 100644 index 0000000..d3f6ce3 --- /dev/null +++ b/src/Werkr.Data.Identity/Services/PermissionService.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +using Werkr.Common.Auth; +using Werkr.Data.Identity.Entities; + +namespace Werkr.Data.Identity.Services; + +/// +/// Checks permissions by querying the table +/// against the current user's roles. +/// +public sealed class PermissionService( WerkrIdentityDbContext dbContext, RoleManager roleManager ) + : IPermissionService { + + /// + public async Task HasPermissionAsync( + IEnumerable roles, + Permission permission, + CancellationToken ct = default ) { + List roleIds = await GetRoleIdsAsync( roles, ct ); + return roleIds.Count != 0 && await dbContext.RolePermissions + .AnyAsync( rp => roleIds.Contains( rp.RoleId ) && rp.Permission == permission, ct ); + } + + /// + public async Task> GetPermissionsAsync( + IEnumerable roles, + CancellationToken ct = default ) { + List roleIds = await GetRoleIdsAsync( roles, ct ); + if (roleIds.Count == 0) { + return new HashSet( ); + } + + List permissions = await dbContext.RolePermissions + .Where( rp => roleIds.Contains( rp.RoleId ) ) + .Select( rp => rp.Permission ) + .Distinct( ) + .ToListAsync( ct ); + + return permissions.ToHashSet( ); + } + + /// + public async Task> GetPermissionsForRoleAsync( + string roleName, + CancellationToken ct = default ) { + IdentityRole? role = await roleManager.FindByNameAsync( roleName ); + if (role is null) { + return []; + } + + List permissions = await dbContext.RolePermissions + .Where( rp => rp.RoleId == role.Id ) + .Select( rp => rp.Permission ) + .Distinct( ) + .ToListAsync( ct ); + + return permissions; + } + + private async Task> GetRoleIdsAsync( IEnumerable roleNames, CancellationToken ct ) { + List roleIds = []; + foreach (string roleName in roleNames) { + if (ct.IsCancellationRequested) { break; } + IdentityRole? role = await roleManager.FindByNameAsync( roleName ); + if (role is not null) { + roleIds.Add( role.Id ); + } + } + return roleIds; + } +} diff --git a/src/Werkr.Data.Identity/SqliteIdentityDesignTimeFactory.cs b/src/Werkr.Data.Identity/SqliteIdentityDesignTimeFactory.cs new file mode 100644 index 0000000..08f852a --- /dev/null +++ b/src/Werkr.Data.Identity/SqliteIdentityDesignTimeFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Werkr.Data.Identity; + +/// +/// Design-time factory for , +/// used by dotnet ef CLI when --context SqliteWerkrIdentityDbContext is specified. +/// +public class SqliteIdentityDesignTimeFactory : IDesignTimeDbContextFactory { + /// + public SqliteWerkrIdentityDbContext CreateDbContext( string[] args ) { + DbContextOptionsBuilder optionsBuilder = new( ); + _ = optionsBuilder.UseSqlite( "Data Source=werkr_identity_design.db" ) + .UseSnakeCaseNamingConvention( ); + return new SqliteWerkrIdentityDbContext( optionsBuilder.Options ); + } +} diff --git a/src/Werkr.Data.Identity/SqliteWerkrIdentityDbContext.cs b/src/Werkr.Data.Identity/SqliteWerkrIdentityDbContext.cs new file mode 100644 index 0000000..dae35d0 --- /dev/null +++ b/src/Werkr.Data.Identity/SqliteWerkrIdentityDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + +namespace Werkr.Data.Identity; + +/// +/// SQLite-specific used for migration generation and runtime resolution. +/// EF Core requires a distinct type per provider so each set of migrations gets its own +/// . +/// +/// Creates a new SQLite-targeted instance. +/// The SQLite-configured options. +public class SqliteWerkrIdentityDbContext( DbContextOptions options ) + : WerkrIdentityDbContext( options ) { } diff --git a/src/Werkr.Data.Identity/Werkr.Data.Identity.csproj b/src/Werkr.Data.Identity/Werkr.Data.Identity.csproj new file mode 100644 index 0000000..5c6db22 --- /dev/null +++ b/src/Werkr.Data.Identity/Werkr.Data.Identity.csproj @@ -0,0 +1,24 @@ + + + + Werkr.Data.Identity + + + + + + + + + + + true + + + + + + + + + diff --git a/src/Werkr.Data.Identity/WerkrIdentityDbContext.cs b/src/Werkr.Data.Identity/WerkrIdentityDbContext.cs new file mode 100644 index 0000000..45bc675 --- /dev/null +++ b/src/Werkr.Data.Identity/WerkrIdentityDbContext.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +using Werkr.Data.Identity.Entities; + +namespace Werkr.Data.Identity; + +/// +/// DbContext for ASP.NET Core Identity with Werkr customizations. +/// Uses a separate schema (werkr_identity) for Postgres. +/// +public class WerkrIdentityDbContext : IdentityDbContext { + /// Creates a new instance configured with the specified options. + /// The strongly-typed options for this context. + public WerkrIdentityDbContext( DbContextOptions options ) : base( options ) { } + + /// Creates a new instance for use by derived provider-specific contexts. + /// The options forwarded from a derived context. + protected WerkrIdentityDbContext( DbContextOptions options ) : base( options ) { } + + /// Role-permission mapping table. + public DbSet RolePermissions => Set( ); + + /// API keys for token-based authentication. + public DbSet ApiKeys => Set( ); + + /// Global server configuration (single row). + public DbSet ConfigurationSettings => Set( ); + + /// + protected override void OnModelCreating( ModelBuilder builder ) { + base.OnModelCreating( builder ); + + // Set schema for Postgres (SQLite doesn't support schemas) + if (Database.ProviderName != "Microsoft.EntityFrameworkCore.Sqlite") { + _ = builder.HasDefaultSchema( "werkr_identity" ); + } + + // Remap Identity tables to snake_case names + _ = builder.Entity( b => { + _ = b.ToTable( "users" ); + _ = b.Property( u => u.Name ).HasMaxLength( 256 ); + } ); + + _ = builder.Entity( b => b.ToTable( "roles" ) ); + _ = builder.Entity>( b => b.ToTable( "user_roles" ) ); + _ = builder.Entity>( b => b.ToTable( "user_claims" ) ); + _ = builder.Entity>( b => { + _ = b.ToTable( "user_logins" ); + _ = b.Property( l => l.LoginProvider ).HasMaxLength( 128 ); + _ = b.Property( l => l.ProviderKey ).HasMaxLength( 128 ); + } ); + _ = builder.Entity>( b => b.ToTable( "role_claims" ) ); + _ = builder.Entity>( b => { + _ = b.ToTable( "user_tokens" ); + _ = b.Property( t => t.LoginProvider ).HasMaxLength( 128 ); + _ = b.Property( t => t.Name ).HasMaxLength( 128 ); + } ); + + // RolePermission join table + _ = builder.Entity( b => { + _ = b.ToTable( "role_permissions" ); + _ = b.HasIndex( rp => new { rp.RoleId, rp.Permission } ).IsUnique( ); + _ = b.HasOne( rp => rp.Role ) + .WithMany( ) + .HasForeignKey( rp => rp.RoleId ) + .OnDelete( DeleteBehavior.Cascade ); + _ = b.Property( rp => rp.Permission ) + .HasConversion( ) + .HasMaxLength( 64 ); + } ); + + // ApiKey table + _ = builder.Entity( b => { + _ = b.ToTable( "api_keys" ); + _ = b.HasKey( k => k.Id ); + _ = b.HasIndex( k => k.KeyHash ).IsUnique( ); + _ = b.HasIndex( k => k.KeyPrefix ); + _ = b.HasOne( k => k.CreatedByUser ) + .WithMany( ) + .HasForeignKey( k => k.CreatedByUserId ) + .OnDelete( DeleteBehavior.Cascade ); + } ); + + // ConfigurationSettings — single-row server config + _ = builder.Entity( b => { + _ = b.ToTable( "config_settings" ); + _ = b.HasKey( c => c.Id ); + } ); + } +} diff --git a/src/Werkr.Data.Identity/packages.lock.json b/src/Werkr.Data.Identity/packages.lock.json new file mode 100644 index 0000000..3c61735 --- /dev/null +++ b/src/Werkr.Data.Identity/packages.lock.json @@ -0,0 +1,355 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "6SEGWi35DZ9syBqCT8v5vEkm9tWUayWxVkHWLwW2FdyXSwS0zzEpIzGPLVQGeug3VU8d+hK/PFxFwwZnblv/zA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "10.0.3" + } + }, + "Microsoft.AspNetCore.Identity.UI": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xhxrP7QcUuyA2FcZsbvdHSqTauPseNrXzhFUYaRj+Elz1nxJceKbW+COc1P9QbpKeZDh9aTDSldHbz3AnMWOqg==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Embedded": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "OPZ/u7fONQFmnyUIDB8SeJtKnyFkj1zJsZ0Ke2Cp17q8hYs6jGmYEFd6Ne4Hdcd6auUdFdV7di+uFo2w+L34NA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.FileProviders.Embedded": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "kw/xPl7m4Gv6bqx2ojihTtWiN2K2AklyMIrvncuSi2MOdwu0oMKoyh0G3p2Brt7m43Q9ER0IaA2G4EGjfgDh/w==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==" + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + } + } + } +} \ No newline at end of file diff --git a/src/Werkr.Data/Calendar/Enums/DaysOfWeek.cs b/src/Werkr.Data/Calendar/Enums/DaysOfWeek.cs new file mode 100644 index 0000000..7822c2e --- /dev/null +++ b/src/Werkr.Data/Calendar/Enums/DaysOfWeek.cs @@ -0,0 +1,22 @@ +namespace Werkr.Data.Calendar.Enums; + +/// Days of the week as flags for recurrence patterns. +[Flags] +public enum DaysOfWeek { + /// No days selected. + None = 0, + /// Monday. + Monday = 1, + /// Tuesday. + Tuesday = 2, + /// Wednesday. + Wednesday = 4, + /// Thursday. + Thursday = 8, + /// Friday. + Friday = 16, + /// Saturday. + Saturday = 32, + /// Sunday. + Sunday = 64, +} diff --git a/src/Werkr.Data/Calendar/Enums/HolidayCalendarMode.cs b/src/Werkr.Data/Calendar/Enums/HolidayCalendarMode.cs new file mode 100644 index 0000000..f207db8 --- /dev/null +++ b/src/Werkr.Data/Calendar/Enums/HolidayCalendarMode.cs @@ -0,0 +1,9 @@ +namespace Werkr.Data.Calendar.Enums; + +/// Determines how a holiday calendar filters schedule occurrences. +public enum HolidayCalendarMode { + /// Occurrences falling on holiday dates are suppressed. + Blocklist, + /// Only occurrences falling on holiday dates are kept. + Allowlist, +} diff --git a/src/Werkr.Data/Calendar/Enums/HolidayRuleType.cs b/src/Werkr.Data/Calendar/Enums/HolidayRuleType.cs new file mode 100644 index 0000000..5e05108 --- /dev/null +++ b/src/Werkr.Data/Calendar/Enums/HolidayRuleType.cs @@ -0,0 +1,11 @@ +namespace Werkr.Data.Calendar.Enums; + +/// The type of algorithmic rule used to compute a holiday date. +public enum HolidayRuleType { + /// A fixed calendar date (e.g., July 4). + FixedDate, + /// The Nth occurrence of a weekday in a month (e.g., 3rd Monday in January). + NthWeekdayOfMonth, + /// The last occurrence of a weekday in a month (e.g., last Monday in May). + LastWeekdayOfMonth, +} diff --git a/src/Werkr.Data/Calendar/Enums/Month.cs b/src/Werkr.Data/Calendar/Enums/Month.cs new file mode 100644 index 0000000..f4c2b8d --- /dev/null +++ b/src/Werkr.Data/Calendar/Enums/Month.cs @@ -0,0 +1,29 @@ +namespace Werkr.Data.Calendar.Enums; + +/// Calendar months (1-based). +public enum Month { + /// January. + January = 1, + /// February. + February = 2, + /// March. + March = 3, + /// April. + April = 4, + /// May. + May = 5, + /// June. + June = 6, + /// July. + July = 7, + /// August. + August = 8, + /// September. + September = 9, + /// October. + October = 10, + /// November. + November = 11, + /// December. + December = 12, +} diff --git a/src/Werkr.Data/Calendar/Enums/MonthsOfYear.cs b/src/Werkr.Data/Calendar/Enums/MonthsOfYear.cs new file mode 100644 index 0000000..76d225a --- /dev/null +++ b/src/Werkr.Data/Calendar/Enums/MonthsOfYear.cs @@ -0,0 +1,35 @@ +namespace Werkr.Data.Calendar.Enums; + +/// +/// Months of year as flags for recurrence patterns. +/// Values are intentionally non-sequential to avoid confusion with month numbers. +/// +[Flags] +public enum MonthsOfYear { + /// No months selected. + None = 0, + /// January. + January = 16, + /// February. + February = 32, + /// March. + March = 64, + /// April. + April = 128, + /// May. + May = 256, + /// June. + June = 512, + /// July. + July = 1024, + /// August. + August = 2048, + /// September. + September = 4096, + /// October. + October = 8192, + /// November. + November = 16384, + /// December. + December = 32768, +} diff --git a/src/Werkr.Data/Calendar/Enums/ObservanceRule.cs b/src/Werkr.Data/Calendar/Enums/ObservanceRule.cs new file mode 100644 index 0000000..535e039 --- /dev/null +++ b/src/Werkr.Data/Calendar/Enums/ObservanceRule.cs @@ -0,0 +1,13 @@ +namespace Werkr.Data.Calendar.Enums; + +/// Rules for how a holiday is observed when it falls on a weekend. +public enum ObservanceRule { + /// No shift — the holiday is observed on its actual date regardless of day of week. + None, + /// Saturday holidays shift to Friday; Sunday holidays shift to Monday. + SaturdayToFriday_SundayToMonday, + /// Saturday holidays shift to the following Monday. + SaturdayToMonday, + /// Observed on the nearest weekday (Saturday → Friday, Sunday → Monday). + NearestWeekday, +} diff --git a/src/Werkr.Data/Calendar/Enums/WeekNumberWithinMonth.cs b/src/Werkr.Data/Calendar/Enums/WeekNumberWithinMonth.cs new file mode 100644 index 0000000..e417d64 --- /dev/null +++ b/src/Werkr.Data/Calendar/Enums/WeekNumberWithinMonth.cs @@ -0,0 +1,20 @@ +namespace Werkr.Data.Calendar.Enums; + +/// Week number within a month as flags for monthly recurrence. +[Flags] +public enum WeekNumberWithinMonth { + /// No week selected. + None = 0, + /// First week. + First = 1, + /// Second week. + Second = 2, + /// Third week. + Third = 4, + /// Fourth week. + Fourth = 8, + /// Fifth week. + Fifth = 16, + /// Sixth week (partial). + Sixth = 32, +} diff --git a/src/Werkr.Data/Calendar/Extensions/CalendarEnumExtensions.cs b/src/Werkr.Data/Calendar/Extensions/CalendarEnumExtensions.cs new file mode 100644 index 0000000..b24f461 --- /dev/null +++ b/src/Werkr.Data/Calendar/Extensions/CalendarEnumExtensions.cs @@ -0,0 +1,292 @@ +using System.Globalization; + +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Collections; +using Werkr.Data.Ranges; + +namespace Werkr.Data.Calendar.Extensions; + +/// +/// Provides extension methods for working with /, +/// /, and enums. +/// +public static class CalendarEnumExtensions { + + #region DaysOfWeek / DayOfWeek + + /// + /// Configurable week start day for ordering operations. + /// + // TODO: inject via IOptions + public static DayOfWeek WeekStartDay { get; set; } = DayOfWeek.Monday; + + /// + /// Converts flags into their counterparts. + /// + /// The flags enum value. + /// A of matching values. + public static List GetDaysOfWeek( this DaysOfWeek daysOfWeek ) { + List result = []; + if (daysOfWeek.HasFlag( DaysOfWeek.Monday )) { result.Add( DayOfWeek.Monday ); } + if (daysOfWeek.HasFlag( DaysOfWeek.Tuesday )) { result.Add( DayOfWeek.Tuesday ); } + if (daysOfWeek.HasFlag( DaysOfWeek.Wednesday )) { result.Add( DayOfWeek.Wednesday ); } + if (daysOfWeek.HasFlag( DaysOfWeek.Thursday )) { result.Add( DayOfWeek.Thursday ); } + if (daysOfWeek.HasFlag( DaysOfWeek.Friday )) { result.Add( DayOfWeek.Friday ); } + if (daysOfWeek.HasFlag( DaysOfWeek.Saturday )) { result.Add( DayOfWeek.Saturday ); } + if (daysOfWeek.HasFlag( DaysOfWeek.Sunday )) { result.Add( DayOfWeek.Sunday ); } + return result; + } + + /// + /// Returns the next in the list after (exclusive). + /// Returns null if the next day would be the . + /// + public static DayOfWeek? GetNextDayInWeek( this List daysOfWeek, DayOfWeek startDay ) { + List result = daysOfWeek.GetRemainingDaysInWeek( startDay, true ); + return result.Count > 0 ? result[0] : null; + } + + /// + /// Gets the remaining days in the week from that are + /// present in . + /// + /// The candidate days to filter. + /// The reference start day. + /// If true, excludes the start day itself. + public static List GetRemainingDaysInWeek( this List daysOfWeek, DayOfWeek startDay, bool exclusive = false ) { + List result = []; + List remainingWeekDays = startDay.GetDaysInWeekFromStartDay( exclusive ); + + foreach (DayOfWeek dayOfWeek in remainingWeekDays) { + if (daysOfWeek.Contains( dayOfWeek )) { + result.Add( dayOfWeek ); + } + } + return result; + } + + /// + /// Returns an ordered list of days from to the end of the week, + /// based on the configured . + /// + /// The day to start from. + /// If true, excludes the start day itself. + public static List GetDaysInWeekFromStartDay( this DayOfWeek startDay, bool exclusive = false ) { + List result = []; + List weekOfDays = GetWeekOfDays( ); + int startPosition = weekOfDays.IndexOf( startDay ); + if (exclusive) { startPosition++; } + for (int i = startPosition; i < weekOfDays.Count; i++) { + result.Add( weekOfDays[i] ); + } + return result; + } + + /// + /// Returns all 7 values in unordered fashion. + /// + public static List GetUnorderedWeekOfDays( ) => + GetDaysOfWeek( (DaysOfWeek)127 ); + + /// + /// Returns all 7 values ordered from the configured . + /// + public static List GetWeekOfDays( ) => + OrderDaysOfWeek( GetUnorderedWeekOfDays( ) ); + + /// + /// Orders the days starting from the configured . + /// + public static List OrderDaysOfWeek( this List daysOfWeek ) => + OrderDaysOfWeek( daysOfWeek, WeekStartDay ); + + /// + /// Orders the days starting from the specified . + /// + public static List OrderDaysOfWeek( this List daysOfWeek, DayOfWeek startDay ) { + List result = []; + LoopingList loopingDays = [.. GetUnorderedWeekOfDays( )]; + loopingDays.CurrentIndex = loopingDays.IndexOf( startDay ); + int count = 1; + foreach (DayOfWeek dayOfWeek in loopingDays) { + if (count > 7) { break; } + if (daysOfWeek.Contains( dayOfWeek )) { + result.Add( dayOfWeek ); + } + count++; + } + return result; + } + + /// + /// Formats flags as a human-readable string using contiguous ranges. + /// + /// The flags to format. + /// + /// When true, uses abbreviations (e.g., "Mon"). + /// When false, uses full names (e.g., "Monday"). + /// + public static string ToString( this DaysOfWeek daysOfWeek, bool abbreviated = false ) + => RangeOfDays.ToString( RangeOfDays.GetContiguousRanges( daysOfWeek ), abbreviated ); + + /// + /// Formats a list of as a human-readable string using contiguous ranges. + /// + public static string ToString( this List daysOfWeek, bool abbreviated = false ) + => RangeOfDays.ToString( RangeOfDays.GetContiguousRanges( daysOfWeek ), abbreviated ); + + /// + /// Formats a single as a string. + /// + /// The day to format. + /// + /// When true, returns the abbreviated name (e.g., "Mon"). + /// When false, returns the full name (e.g., "Monday"). + /// + public static string ToString( this DayOfWeek day, bool abbreviated ) + => abbreviated + ? DateTimeFormatInfo.CurrentInfo.GetAbbreviatedDayName( day ) + : DateTimeFormatInfo.CurrentInfo.GetDayName( day ); + + #endregion DaysOfWeek / DayOfWeek + + + #region MonthsOfYear / Month + + /// + /// Filters to months after the given . + /// + /// The candidate month integers (1–12). + /// The reference month number. + /// If true, excludes the start month itself. + public static int[] GetRemainingMonthsInYear( this int[] months, int startMonth, bool exclusive = false ) => + exclusive + ? [.. months.Where( month => month > startMonth )] + : [.. months.Where( month => month >= startMonth )]; + + /// + /// Converts flags into their integer counterparts (1–12). + /// + public static int[] GetIntMonths( this MonthsOfYear monthsOfYear ) + => [.. GetIntMonths( monthsOfYear.GetMonths( ) )]; + + /// + /// Converts a list of values into their integer counterparts (1–12). + /// + public static int[] GetIntMonths( this List monthsOfYear ) + => [.. monthsOfYear.Select( month => (int)month )]; + + /// + /// Converts flags into a list of values. + /// + public static List GetMonths( this MonthsOfYear monthsOfYear ) { + List result = []; + if (monthsOfYear.HasFlag( MonthsOfYear.January )) { result.Add( Month.January ); } + if (monthsOfYear.HasFlag( MonthsOfYear.February )) { result.Add( Month.February ); } + if (monthsOfYear.HasFlag( MonthsOfYear.March )) { result.Add( Month.March ); } + if (monthsOfYear.HasFlag( MonthsOfYear.April )) { result.Add( Month.April ); } + if (monthsOfYear.HasFlag( MonthsOfYear.May )) { result.Add( Month.May ); } + if (monthsOfYear.HasFlag( MonthsOfYear.June )) { result.Add( Month.June ); } + if (monthsOfYear.HasFlag( MonthsOfYear.July )) { result.Add( Month.July ); } + if (monthsOfYear.HasFlag( MonthsOfYear.August )) { result.Add( Month.August ); } + if (monthsOfYear.HasFlag( MonthsOfYear.September )) { result.Add( Month.September ); } + if (monthsOfYear.HasFlag( MonthsOfYear.October )) { result.Add( Month.October ); } + if (monthsOfYear.HasFlag( MonthsOfYear.November )) { result.Add( Month.November ); } + if (monthsOfYear.HasFlag( MonthsOfYear.December )) { result.Add( Month.December ); } + return result; + } + + /// + /// Converts a list of values back to flags. + /// + public static MonthsOfYear GetMonths( this List monthsOfYear ) { + MonthsOfYear result = new( ); + foreach (Month month in monthsOfYear) { + result |= month switch { + Month.January => MonthsOfYear.January, + Month.February => MonthsOfYear.February, + Month.March => MonthsOfYear.March, + Month.April => MonthsOfYear.April, + Month.May => MonthsOfYear.May, + Month.June => MonthsOfYear.June, + Month.July => MonthsOfYear.July, + Month.August => MonthsOfYear.August, + Month.September => MonthsOfYear.September, + Month.October => MonthsOfYear.October, + Month.November => MonthsOfYear.November, + Month.December => MonthsOfYear.December, + _ => 0, + }; + } + return result; + } + + /// + /// Formats flags as a human-readable string using contiguous ranges. + /// + /// The flags to format. + /// + /// When true, uses abbreviations (e.g., "Jan"). + /// When false, uses full names (e.g., "January"). + /// + public static string ToString( this MonthsOfYear monthsOfYear, bool abbreviated = false ) + => RangeOfMonths.ToString( RangeOfMonths.GetContiguousRanges( monthsOfYear ), abbreviated ); + + /// + /// Formats a list of values as a human-readable string using contiguous ranges. + /// + public static string ToString( this List monthsOfYear, bool abbreviated = false ) + => RangeOfMonths.ToString( RangeOfMonths.GetContiguousRanges( monthsOfYear ), abbreviated ); + + /// + /// Formats a single as a string. + /// + /// The month to format. + /// + /// When true, returns the abbreviated name (e.g., "Jan"). + /// When false, returns the full name (e.g., "January"). + /// + public static string ToString( this Month month, bool abbreviated ) + => abbreviated + ? DateTimeFormatInfo.CurrentInfo.GetAbbreviatedMonthName( (int)month ) + : DateTimeFormatInfo.CurrentInfo.GetMonthName( (int)month ); + + /// + /// Converts a 1-based month number (1–12) to the corresponding enum name string. + /// + /// The month number (1 = January, 12 = December). + public static string GetMonthNameFromMonthNum( this int month ) + => DateTimeFormatInfo.CurrentInfo.GetMonthName( month ); + + #endregion MonthsOfYear / Month + + + #region WeekNumberWithinMonth + + /// + /// Converts flags into an array of 1-based week numbers. + /// + public static int[] GetWeekNumbersInMonth( this WeekNumberWithinMonth weekNums ) { + List result = []; + if (weekNums.HasFlag( WeekNumberWithinMonth.First )) { result.Add( 1 ); } + if (weekNums.HasFlag( WeekNumberWithinMonth.Second )) { result.Add( 2 ); } + if (weekNums.HasFlag( WeekNumberWithinMonth.Third )) { result.Add( 3 ); } + if (weekNums.HasFlag( WeekNumberWithinMonth.Fourth )) { result.Add( 4 ); } + if (weekNums.HasFlag( WeekNumberWithinMonth.Fifth )) { result.Add( 5 ); } + if (weekNums.HasFlag( WeekNumberWithinMonth.Sixth )) { result.Add( 6 ); } + return [.. result]; + } + + /// + /// Formats flags as a human-readable string using contiguous ranges. + /// + /// The flags to format. + /// + /// When true, uses numeric representation (e.g., "1"). + /// When false, uses ordinal names (e.g., "First"). + /// + public static string ToString( this WeekNumberWithinMonth weekNums, bool abbreviated = false ) + => RangeOfWeekNums.ToString( RangeOfWeekNums.GetContiguousRanges( weekNums ), abbreviated ); + + #endregion WeekNumberWithinMonth +} diff --git a/src/Werkr.Data/Calendar/Models/Schedule.cs b/src/Werkr.Data/Calendar/Models/Schedule.cs new file mode 100644 index 0000000..9e03f11 --- /dev/null +++ b/src/Werkr.Data/Calendar/Models/Schedule.cs @@ -0,0 +1,36 @@ +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Data.Calendar.Models; + +/// +/// Composite model that aggregates all schedule-related entities into a single in-memory view. +/// +public class Schedule { + /// The core schedule entity. + public required DbSchedule DbSchedule { get; set; } + + /// Start date/time/timezone information. + public StartDateTimeInfo? StartDateTime { get; set; } + + /// Expiration date/time/timezone information. + public ExpirationDateTimeInfo? Expiration { get; set; } + + /// Daily recurrence pattern. + public DailyRecurrence? DailyRecurrence { get; set; } + + /// Weekly recurrence pattern. + public WeeklyRecurrence? WeeklyRecurrence { get; set; } + + /// Monthly recurrence pattern. + public MonthlyRecurrence? MonthlyRecurrence { get; set; } + + /// Repeat options for the schedule. + public ScheduleRepeatOptions? RepeatOptions { get; set; } + + /// Attached holiday calendar (loaded by service layer). + public HolidayCalendar? HolidayCalendar { get; set; } + + /// Mode of the attached holiday calendar (blocklist/allowlist). + public HolidayCalendarMode? HolidayCalendarMode { get; set; } +} diff --git a/src/Werkr.Data/Calendar/Validation/HolidayRuleValidator.cs b/src/Werkr.Data/Calendar/Validation/HolidayRuleValidator.cs new file mode 100644 index 0000000..ac890de --- /dev/null +++ b/src/Werkr.Data/Calendar/Validation/HolidayRuleValidator.cs @@ -0,0 +1,106 @@ +using System.ComponentModel.DataAnnotations; + +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Data.Calendar.Validation; + +/// +/// Validates a entity. +/// Invoked programmatically via during rule creation and updates. +/// +public static class HolidayRuleValidator { + /// + /// Validates the and returns + /// if valid, or a describing the first violation. + /// + public static ValidationResult? Validate( HolidayRule rule ) { + // Name is required + if (string.IsNullOrWhiteSpace( rule.Name )) { + return new ValidationResult( "Name is required." ); + } + + // Month is required for all rule types + if (!rule.Month.HasValue) { + return new ValidationResult( "Month is required." ); + } + if (rule.Month.Value is < 1 or > 12) { + return new ValidationResult( "Month must be between 1 and 12." ); + } + + // Rule-type-specific validation + ValidationResult? typeResult = rule.RuleType switch { + HolidayRuleType.FixedDate => ValidateFixedDate( rule ), + HolidayRuleType.NthWeekdayOfMonth => ValidateNthWeekday( rule ), + HolidayRuleType.LastWeekdayOfMonth => ValidateLastWeekday( rule ), + _ => new ValidationResult( $"Unknown RuleType: {rule.RuleType}." ), + }; + if (typeResult != ValidationResult.Success) { + return typeResult; + } + + // ObservanceRule: Non-None only valid for FixedDate + if (rule.ObservanceRule != ObservanceRule.None && rule.RuleType != HolidayRuleType.FixedDate) { + return new ValidationResult( "ObservanceRule must be None for pattern-based rules (NthWeekdayOfMonth, LastWeekdayOfMonth)." ); + } + + // Time window: all-or-nothing + bool hasStart = rule.WindowStart.HasValue; + bool hasEnd = rule.WindowEnd.HasValue; + bool hasTz = !string.IsNullOrWhiteSpace( rule.WindowTimeZoneId ); + + if (hasStart || hasEnd || hasTz) { + if (!hasStart || !hasEnd || !hasTz) { + return new ValidationResult( "WindowStart, WindowEnd, and WindowTimeZoneId must all be set or all be null." ); + } + if (rule.WindowStart!.Value >= rule.WindowEnd!.Value) { + return new ValidationResult( "WindowStart must be earlier than WindowEnd." ); + } + try { + _ = TimeZoneInfo.FindSystemTimeZoneById( rule.WindowTimeZoneId! ); + } catch (TimeZoneNotFoundException) { + return new ValidationResult( $"WindowTimeZoneId '{rule.WindowTimeZoneId}' is not a valid timezone identifier." ); + } + } + + // Year bounds + return rule.YearStart.HasValue && rule.YearEnd.HasValue && rule.YearStart.Value > rule.YearEnd.Value + ? new ValidationResult( "YearStart must be less than or equal to YearEnd." ) + : ValidationResult.Success; + } + + private static ValidationResult? ValidateFixedDate( HolidayRule rule ) { + if (!rule.Day.HasValue) { + return new ValidationResult( "Day is required for FixedDate rules." ); + } + if (rule.Day.Value is < 1 or > 31) { + return new ValidationResult( "Day must be between 1 and 31." ); + } + // Allow day 29 for Feb (leap years) — validated at computation time + return rule.Month.HasValue && rule.Day.Value > 29 && rule.Month.Value == 2 + ? new ValidationResult( "February cannot have a day greater than 29." ) + : rule.WeekNumber.HasValue + ? new ValidationResult( "WeekNumber must not be set for FixedDate rules." ) + : rule.DayOfWeek.HasValue ? new ValidationResult( "DayOfWeek must not be set for FixedDate rules." ) : ValidationResult.Success; + } + + private static ValidationResult? ValidateNthWeekday( HolidayRule rule ) { + return !rule.DayOfWeek.HasValue + ? new ValidationResult( "DayOfWeek is required for NthWeekdayOfMonth rules." ) + : !rule.WeekNumber.HasValue + ? new ValidationResult( "WeekNumber is required for NthWeekdayOfMonth rules." ) + : rule.WeekNumber.Value is < 1 or > 5 + ? new ValidationResult( "WeekNumber must be between 1 and 5." ) + : rule.Day.HasValue ? new ValidationResult( "Day must not be set for NthWeekdayOfMonth rules." ) : ValidationResult.Success; + } + + private static ValidationResult? ValidateLastWeekday( HolidayRule rule ) { + return !rule.DayOfWeek.HasValue + ? new ValidationResult( "DayOfWeek is required for LastWeekdayOfMonth rules." ) + : rule.Day.HasValue + ? new ValidationResult( "Day must not be set for LastWeekdayOfMonth rules." ) + : rule.WeekNumber.HasValue + ? new ValidationResult( "WeekNumber must not be set for LastWeekdayOfMonth rules." ) + : ValidationResult.Success; + } +} diff --git a/src/Werkr.Data/Calendar/Validation/MonthlyRecurrenceValidator.cs b/src/Werkr.Data/Calendar/Validation/MonthlyRecurrenceValidator.cs new file mode 100644 index 0000000..4d18692 --- /dev/null +++ b/src/Werkr.Data/Calendar/Validation/MonthlyRecurrenceValidator.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; + +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Data.Calendar.Validation; + +/// +/// Validates a entity. +/// Invoked programmatically via during schedule validation. +/// +public static class MonthlyRecurrenceValidator { + /// + /// Validates the and returns a + /// or if valid. + /// + public static ValidationResult? Validate( MonthlyRecurrence recurrence ) { + // MonthsOfYear must not be None + if (recurrence.MonthsOfYear == 0) { + return new ValidationResult( "MonthlyRecurrence.MonthsOfYear must not be None." ); + } + + bool hasDayNumbers = recurrence.DayNumbers is { Length: > 0 }; + bool hasWeekNumber = recurrence.WeekNumber is not null and not 0; + bool hasDaysOfWeek = recurrence.DaysOfWeek is not null and not 0; + + // Must set either DayNumbers OR (WeekNumber + DaysOfWeek), but not both + if (hasDayNumbers && (hasWeekNumber || hasDaysOfWeek)) { + return new ValidationResult( + "MonthlyRecurrence: DayNumbers cannot be combined with WeekNumber or DaysOfWeek. " + + "Use day-of-month mode (DayNumbers) or week-and-day mode (WeekNumber + DaysOfWeek), not both." ); + } + + if (!hasDayNumbers && !hasWeekNumber && !hasDaysOfWeek) { + return new ValidationResult( + "MonthlyRecurrence: Either DayNumbers or (WeekNumber + DaysOfWeek) must be set." ); + } + + // WeekNumber and DaysOfWeek must be set together + if (hasWeekNumber != hasDaysOfWeek) { + return new ValidationResult( + "MonthlyRecurrence: WeekNumber and DaysOfWeek must be set together (both or neither)." ); + } + + // Validate DayNumbers values + if (hasDayNumbers) { + foreach (int day in recurrence.DayNumbers!) { + if (day == 0) { + return new ValidationResult( "MonthlyRecurrence.DayNumbers values must not be 0." ); + } + if (day is < -31 or > 31) { + return new ValidationResult( + $"MonthlyRecurrence.DayNumbers values must be between -31 and 31. Found: {day}." ); + } + } + } + + return ValidationResult.Success; + } +} diff --git a/src/Werkr.Data/Calendar/Validation/ScheduleValidator.cs b/src/Werkr.Data/Calendar/Validation/ScheduleValidator.cs new file mode 100644 index 0000000..c6b6854 --- /dev/null +++ b/src/Werkr.Data/Calendar/Validation/ScheduleValidator.cs @@ -0,0 +1,74 @@ +using System.ComponentModel.DataAnnotations; + +using Werkr.Data.Calendar.Models; + +namespace Werkr.Data.Calendar.Validation; + +/// +/// Validates a composite model. +/// Invoked programmatically via during schedule creation and updates. +/// +public static class ScheduleValidator { + /// + /// Validates the composite model and returns a + /// or if valid. + /// + public static ValidationResult? Validate( Schedule schedule ) { + // StartDateTime is required + if (schedule.StartDateTime is null) { + return new ValidationResult( "StartDateTime is required." ); + } + + // At most one recurrence type + int recurrenceCount = 0; + if (schedule.DailyRecurrence is not null) { + recurrenceCount++; + } + + if (schedule.WeeklyRecurrence is not null) { + recurrenceCount++; + } + + if (schedule.MonthlyRecurrence is not null) { + recurrenceCount++; + } + + if (recurrenceCount > 1) { + return new ValidationResult( "At most one recurrence type may be set (daily, weekly, or monthly)." ); + } + + // Daily validation + if (schedule.DailyRecurrence is not null) { + if (schedule.DailyRecurrence.DayInterval < 1) { + return new ValidationResult( "DailyRecurrence.DayInterval must be >= 1." ); + } + } + + // Weekly validation + if (schedule.WeeklyRecurrence is not null) { + if (schedule.WeeklyRecurrence.WeekInterval < 1) { + return new ValidationResult( "WeeklyRecurrence.WeekInterval must be >= 1." ); + } + if (schedule.WeeklyRecurrence.DaysOfWeek == 0) { + return new ValidationResult( "WeeklyRecurrence.DaysOfWeek must not be None." ); + } + } + + // Monthly validation + if (schedule.MonthlyRecurrence is not null) { + ValidationResult? monthlyResult = MonthlyRecurrenceValidator.Validate( schedule.MonthlyRecurrence ); + if (monthlyResult != ValidationResult.Success) { + return monthlyResult; + } + } + + // RepeatOptions validation + if (schedule.RepeatOptions is not null) { + if (schedule.RepeatOptions.RepeatIntervalMinutes < 1) { + return new ValidationResult( "RepeatOptions.RepeatIntervalMinutes must be >= 1." ); + } + } + + return ValidationResult.Success; + } +} diff --git a/src/Werkr.Data/Calendar/Validation/WerkrTaskValidator.cs b/src/Werkr.Data/Calendar/Validation/WerkrTaskValidator.cs new file mode 100644 index 0000000..abe3228 --- /dev/null +++ b/src/Werkr.Data/Calendar/Validation/WerkrTaskValidator.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Data.Calendar.Validation; + +/// +/// Validates a entity. +/// Invoked programmatically via during task creation and updates. +/// +public static class WerkrTaskValidator { + /// + /// Validates the and returns a + /// or if valid. + /// + public static ValidationResult? Validate( WerkrTask task ) { + // Content must not be empty or whitespace + if (string.IsNullOrWhiteSpace( task.Content )) { + return new ValidationResult( "WerkrTask.Content must not be empty or whitespace." ); + } + + // ActionType must be a defined enum value + if (!Enum.IsDefined( task.ActionType )) { + return new ValidationResult( + $"WerkrTask.ActionType must be a valid TaskActionType value. Found: {(int)task.ActionType}." ); + } + + // A task with neither ScheduleId nor WorkflowId is valid (ad-hoc-only task — Decision #43) + + return ValidationResult.Success; + } +} diff --git a/src/Werkr.Data/Collections/LoopingList.cs b/src/Werkr.Data/Collections/LoopingList.cs new file mode 100644 index 0000000..9bf392f --- /dev/null +++ b/src/Werkr.Data/Collections/LoopingList.cs @@ -0,0 +1,128 @@ +using System.Collections; + +namespace Werkr.Data.Collections; + +/// +/// Represents a looping list that continuously iterates through its elements. +/// +/// +/// +/// The provides continuous iteration over its elements, +/// wrapping around from the end to the beginning. It implements , +/// , and . +/// +/// +/// When used in a foreach loop, it will not automatically break — +/// consumers must manually break to exit the loop. +/// +/// +/// +/// Continuous iteration +/// Navigates through elements indefinitely. +/// +/// +/// Starting element +/// Optionally specify the starting element for the loop. +/// +/// +/// Mutable +/// Allows adding, removing, and modifying elements like a regular list. +/// +/// +/// +/// The type of elements in the list. +public class LoopingList : IEnumerable, ICollection, IList { + + /// + /// Initializes an empty looping list. + /// + public LoopingList( ) { + _items = []; + } + + /// + /// Initializes a looping list with elements from a provided collection. + /// + /// The collection of elements to add to the list. + public LoopingList( IEnumerable items ) { + _items = [.. items]; + } + + /// + /// Initializes a looping list with elements from a collection and sets the starting element. + /// + /// The collection of elements to add to the list. + /// The element to start the iteration from. + public LoopingList( IEnumerable items, T startingElement ) { + _items = [.. items]; + if (startingElement is not null && _items.Contains( startingElement )) { + CurrentIndex = _items.IndexOf( startingElement ); + } + } + + /// The internal backing list. + private readonly List _items; + + /// The current iteration index within the list. + public int CurrentIndex { get; set; } + + #region ICollection + + /// + public int Count => _items.Count; + + /// + public bool IsReadOnly => false; + + /// + public void Add( T item ) => _items.Add( item ); + + /// + public void Clear( ) => _items.Clear( ); + + /// + public bool Contains( T item ) => _items.Contains( item ); + + /// + public void CopyTo( T[] array, int arrayIndex ) => _items.CopyTo( array, arrayIndex ); + + /// + public bool Remove( T item ) => _items.Remove( item ); + + #endregion ICollection + + #region IList + + /// + /// Indexes wrap around using modulo arithmetic. Negative indexes are not supported. + public T this[int index] { get => _items[index % _items.Count]; set => _items[index % _items.Count] = value; } + + /// + public int IndexOf( T item ) => _items.IndexOf( item ); + + /// + public void Insert( int index, T item ) => _items.Insert( index, item ); + + /// + public void RemoveAt( int index ) => _items.RemoveAt( index ); + + #endregion IList + + #region IEnumerable + + /// + /// Returns an enumerator that loops continuously through the list. + /// Consumers must break manually to avoid infinite iteration. + /// + public IEnumerator GetEnumerator( ) { + while (true) { + yield return _items[CurrentIndex]; + CurrentIndex = (CurrentIndex + 1) % _items.Count; + } + } + + /// + IEnumerator IEnumerable.GetEnumerator( ) => GetEnumerator( ); + + #endregion IEnumerable +} diff --git a/src/Werkr.Data/Encryption/EncryptedByteArrayConverter.cs b/src/Werkr.Data/Encryption/EncryptedByteArrayConverter.cs new file mode 100644 index 0000000..6e647c1 --- /dev/null +++ b/src/Werkr.Data/Encryption/EncryptedByteArrayConverter.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Werkr.Data.Encryption; + +/// +/// EF Core value converter that transparently encrypts a [] property +/// to a Base64 string before writing and decrypts on read. +/// Uses (AES-256-GCM). +/// +public sealed class EncryptedByteArrayConverter : ValueConverter { + /// Creates a new converter backed by the specified encryption provider. + /// The AES-256-GCM encryption provider. + public EncryptedByteArrayConverter( FieldEncryptionProvider provider ) + : base( + v => provider.EncryptBytes( v )!, + v => provider.DecryptBytes( v )! ) { } +} diff --git a/src/Werkr.Data/Encryption/EncryptedStringConverter.cs b/src/Werkr.Data/Encryption/EncryptedStringConverter.cs new file mode 100644 index 0000000..fa572ac --- /dev/null +++ b/src/Werkr.Data/Encryption/EncryptedStringConverter.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Werkr.Data.Encryption; + +/// +/// EF Core value converter that transparently encrypts a property +/// before writing to the database and decrypts it after reading. +/// Uses (AES-256-GCM). +/// +public sealed class EncryptedStringConverter : ValueConverter { + /// Creates a new converter backed by the specified encryption provider. + /// The AES-256-GCM encryption provider. + public EncryptedStringConverter( FieldEncryptionProvider provider ) + : base( + v => provider.Encrypt( v )!, + v => provider.Decrypt( v )! ) { } +} diff --git a/src/Werkr.Data/Encryption/FieldEncryptionProvider.cs b/src/Werkr.Data/Encryption/FieldEncryptionProvider.cs new file mode 100644 index 0000000..584ba9a --- /dev/null +++ b/src/Werkr.Data/Encryption/FieldEncryptionProvider.cs @@ -0,0 +1,159 @@ +using System.Security.Cryptography; + +namespace Werkr.Data.Encryption; + +/// +/// Provides transparent field-level encryption using AES-256-GCM for sensitive +/// columns +/// (OutboundApiKey, LocalPrivateKey, SharedKey). +/// +/// The symmetric passphrase is sourced from the OS secret store via +/// ISecretStore (DPAPI on Windows, Keychain on macOS, libsecret on Linux). +/// A new passphrase is auto-generated on first use and stored under the key +/// werkr-pgcrypto-passphrase. +/// +/// +/// This implementation performs encryption at the application level, making it +/// database-provider-agnostic. When the Agent runs on Postgres, the encrypted +/// bytea payload is stored directly; when on SQLite/SQLCipher, it is a +/// secondary layer on top of the whole-DB encryption. +/// +/// +public sealed class FieldEncryptionProvider { + private readonly byte[] _key; + + /// + /// Initialises a new with a 32-byte encryption key. + /// + /// Base64-encoded 32-byte AES-256 key from the OS secret store. + /// Thrown when the decoded key is not exactly 32 bytes. + public FieldEncryptionProvider( string base64Key ) { + _key = Convert.FromBase64String( base64Key ); + if (_key.Length != 32) { + throw new ArgumentException( "Encryption key must be exactly 32 bytes (256 bits).", nameof( base64Key ) ); + } + } + + /// The OS secret store key under which the passphrase is stored. + public const string SecretStoreKey = "werkr-pgcrypto-passphrase"; + + /// + /// Generates a new random 32-byte key encoded as Base64. + /// + public static string GenerateKey( ) => + Convert.ToBase64String( RandomNumberGenerator.GetBytes( 32 ) ); + + /// + /// Encrypts a plaintext string using AES-256-GCM and returns a Base64-encoded ciphertext + /// (nonce ‖ ciphertext ‖ tag). + /// + /// The value to encrypt. + /// Base64-encoded encrypted blob, or null if is null. + public string? Encrypt( string? plaintext ) { + if (plaintext is null) { + return null; + } + + byte[] plaintextBytes = System.Text.Encoding.UTF8.GetBytes( plaintext ); + byte[] nonce = new byte[AesGcm.NonceByteSizes.MaxSize]; // 12 bytes + RandomNumberGenerator.Fill( nonce ); + + byte[] ciphertext = new byte[plaintextBytes.Length]; + byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize]; // 16 bytes + + using AesGcm aes = new( _key, AesGcm.TagByteSizes.MaxSize ); + aes.Encrypt( nonce, plaintextBytes, ciphertext, tag ); + + // Pack: [nonce (12)] [ciphertext (N)] [tag (16)] + byte[] result = new byte[nonce.Length + ciphertext.Length + tag.Length]; + nonce.CopyTo( result, 0 ); + ciphertext.CopyTo( result, nonce.Length ); + tag.CopyTo( result, nonce.Length + ciphertext.Length ); + + return Convert.ToBase64String( result ); + } + + /// + /// Decrypts a Base64-encoded AES-256-GCM blob (nonce ‖ ciphertext ‖ tag) back to plaintext. + /// + /// The Base64-encoded encrypted blob. + /// The decrypted plaintext, or null if is null. + /// If the tag is invalid (data tampered). + public string? Decrypt( string? encryptedBase64 ) { + if (encryptedBase64 is null) { + return null; + } + + byte[] blob = Convert.FromBase64String( encryptedBase64 ); + const int NonceSize = 12; + const int TagSize = 16; + + if (blob.Length < NonceSize + TagSize) { + throw new ArgumentException( "Encrypted blob is too short.", nameof( encryptedBase64 ) ); + } + + byte[] nonce = blob[..NonceSize]; + byte[] tag = blob[^TagSize..]; + byte[] ciphertext = blob[NonceSize..^TagSize]; + + byte[] plaintext = new byte[ciphertext.Length]; + + using AesGcm aes = new( _key, AesGcm.TagByteSizes.MaxSize ); + aes.Decrypt( nonce, ciphertext, tag, plaintext ); + + return System.Text.Encoding.UTF8.GetString( plaintext ); + } + + /// + /// Encrypts a byte array using AES-256-GCM and returns a Base64-encoded ciphertext. + /// + public string? EncryptBytes( byte[]? data ) { + if (data is null || data.Length == 0) { + return null; + } + + byte[] nonce = new byte[AesGcm.NonceByteSizes.MaxSize]; + RandomNumberGenerator.Fill( nonce ); + + byte[] ciphertext = new byte[data.Length]; + byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize]; + + using AesGcm aes = new( _key, AesGcm.TagByteSizes.MaxSize ); + aes.Encrypt( nonce, data, ciphertext, tag ); + + byte[] result = new byte[nonce.Length + ciphertext.Length + tag.Length]; + nonce.CopyTo( result, 0 ); + ciphertext.CopyTo( result, nonce.Length ); + tag.CopyTo( result, nonce.Length + ciphertext.Length ); + + return Convert.ToBase64String( result ); + } + + /// + /// Decrypts a Base64-encoded AES-256-GCM blob back to a byte array. + /// + public byte[]? DecryptBytes( string? encryptedBase64 ) { + if (encryptedBase64 is null) { + return null; + } + + byte[] blob = Convert.FromBase64String( encryptedBase64 ); + const int NonceSize = 12; + const int TagSize = 16; + + if (blob.Length < NonceSize + TagSize) { + throw new ArgumentException( "Encrypted blob is too short.", nameof( encryptedBase64 ) ); + } + + byte[] nonce = blob[..NonceSize]; + byte[] tag = blob[^TagSize..]; + byte[] ciphertext = blob[NonceSize..^TagSize]; + + byte[] plaintext = new byte[ciphertext.Length]; + + using AesGcm aes = new( _key, AesGcm.TagByteSizes.MaxSize ); + aes.Decrypt( nonce, ciphertext, tag, plaintext ); + + return plaintext; + } +} diff --git a/src/Werkr.Data/Entities/ConcurrencyBase.cs b/src/Werkr.Data/Entities/ConcurrencyBase.cs new file mode 100644 index 0000000..7390917 --- /dev/null +++ b/src/Werkr.Data/Entities/ConcurrencyBase.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Werkr.Data.Entities; + +/// +/// Abstract base class providing concurrency tracking fields. +/// All entities that need optimistic concurrency should inherit from this. +/// +public abstract class ConcurrencyBase { + /// Timestamp when the entity was first created (UTC). + [Required] + public DateTime Created { get; set; } + + /// Timestamp when the entity was last updated (UTC). + [Required] + public DateTime LastUpdated { get; set; } + + /// + /// Optimistic concurrency version. Incremented on each save. + /// + [ConcurrencyCheck] + public int Version { get; set; } +} diff --git a/src/Werkr.Data/Entities/Interfaces/IKey.cs b/src/Werkr.Data/Entities/Interfaces/IKey.cs new file mode 100644 index 0000000..297751d --- /dev/null +++ b/src/Werkr.Data/Entities/Interfaces/IKey.cs @@ -0,0 +1,10 @@ +namespace Werkr.Data.Entities.Interfaces; + +/// +/// Generic interface for entities with a typed primary key. +/// +/// The type of the primary key. +public interface IKey { + /// Primary key. + T Id { get; set; } +} diff --git a/src/Werkr.Data/Entities/Registration/RegisteredConnection.cs b/src/Werkr.Data/Entities/Registration/RegisteredConnection.cs new file mode 100644 index 0000000..8c02010 --- /dev/null +++ b/src/Werkr.Data/Entities/Registration/RegisteredConnection.cs @@ -0,0 +1,114 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Security.Cryptography; + +using Werkr.Common.Models; +using Werkr.Data.Entities.Interfaces; + +namespace Werkr.Data.Entities.Registration; + +/// +/// Represents an established trust relationship between a Server and an Agent. +/// Both sides persist a after a successful registration handshake. +/// Each side stores its own RSA key pair, the remote party's RSA public key, +/// a pre-shared AES-256 symmetric key, and an API key (Server stores hash, Agent stores raw). +/// +[Table( "registered_connections" )] +public class RegisteredConnection : ConcurrencyBase, IKey { + /// Unique identifier. Also serves as the connection ID in all post-registration communication. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public Guid Id { get; set; } + + /// Human-readable label for this connection, max 256 characters. + [Required] + [MaxLength( 256 )] + public string ConnectionName { get; set; } = string.Empty; + + /// The other party's gRPC endpoint URL, max 2048 characters. + [Required] + [MaxLength( 2048 )] + public string RemoteUrl { get; set; } = string.Empty; + + /// Our RSA public key parameters. + public RSAParameters LocalPublicKey { get; set; } + + /// Our RSA private key parameters. + public RSAParameters LocalPrivateKey { get; set; } + + /// The remote party's RSA public key parameters. + public RSAParameters RemotePublicKey { get; set; } + + /// + /// API key for calling the remote party (outbound bearer token), max 512 characters. + /// Agent stores the raw Agent→Server key; Server stores the raw Server→Agent key. + /// Protected at rest by SQLCipher (Agent) or database-level security (Server Postgres). + /// + [MaxLength( 512 )] + public string OutboundApiKey { get; set; } = string.Empty; + + /// + /// SHA-3-512 hash of the API key the remote party uses to call us (inbound validation), max 512 characters. + /// Server stores hash of Agent→Server key; Agent stores hash of Server→Agent key. + /// + [MaxLength( 512 )] + public string InboundApiKeyHash { get; set; } = string.Empty; + + /// + /// 32-byte AES-256 symmetric key for ongoing message payload encryption. + /// Generated by Server during registration, delivered to Agent via hybrid encryption. + /// + [Required] + public byte[] SharedKey { get; set; } = []; + + /// + /// Previous shared key retained after a RotateSharedKey RPC so that in-flight + /// messages encrypted with the old key can still be decrypted during the transition window. + /// Null when no rotation has occurred or the previous key has been cleared. + /// + public byte[]? PreviousSharedKey { get; set; } + + /// + /// Opaque identifier for , placed in the key_id field of + /// every . Defaults to + /// .ToString() when null (pre-rotation connections). + /// + [MaxLength( 128 )] + public string? ActiveKeyId { get; set; } + + /// + /// Opaque identifier for . + /// Used to match incoming envelopes that were encrypted before the most recent rotation. + /// + [MaxLength( 128 )] + public string? PreviousKeyId { get; set; } + + /// True if this side is the Server in the relationship; false if Agent. + public bool IsServer { get; set; } + + /// Current status of this connection. + public ConnectionStatus Status { get; set; } = ConnectionStatus.Connected; + + /// Timestamp of last successful communication with the remote party (UTC). Null if never communicated. + public DateTime? LastSeen { get; set; } + + /// + /// Tags assigned to this agent for task targeting. Tasks specify TargetTags + /// and agents are matched via case-insensitive tag intersection. + /// + public string[] Tags { get; set; } = []; + + /// + /// Allowed filesystem path prefixes for built-in action operations. + /// When is true, all file/process action + /// handler paths are validated against these prefixes on the Agent before execution. + /// Stored as a JSON column, following the same pattern as . + /// + public string[] AllowedPaths { get; set; } = []; + + /// + /// When true, the Agent enforces restrictions + /// on all built-in action handlers. Default false preserves backward compatibility. + /// + public bool EnforceAllowlist { get; set; } +} diff --git a/src/Werkr.Data/Entities/Registration/RegistrationBundle.cs b/src/Werkr.Data/Entities/Registration/RegistrationBundle.cs new file mode 100644 index 0000000..fbe293a --- /dev/null +++ b/src/Werkr.Data/Entities/Registration/RegistrationBundle.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Security.Cryptography; + +using Werkr.Common.Models; +using Werkr.Data.Entities.Interfaces; + +namespace Werkr.Data.Entities.Registration; + +/// +/// Tracks a pending registration bundle on the Server side. +/// Created when an admin generates a registration bundle and persisted +/// until the Agent completes the handshake or the bundle expires. +/// +[Table( "registration_bundles" )] +public class RegistrationBundle : ConcurrencyBase, IKey { + /// Unique identifier. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public Guid Id { get; set; } + + /// Admin-assigned label for this registration, max 256 characters. + [Required] + [MaxLength( 256 )] + public string ConnectionName { get; set; } = string.Empty; + + /// Server's RSA public key generated for this registration. + public RSAParameters ServerPublicKey { get; set; } + + /// Server's RSA private key (used to decrypt the Agent's response). + public RSAParameters ServerPrivateKey { get; set; } + + /// 16-byte random correlation token embedded in the encrypted bundle. + [Required] + public byte[] BundleId { get; set; } = []; + + /// Current status of this registration bundle. + public RegistrationStatus Status { get; set; } = RegistrationStatus.Pending; + + /// UTC timestamp when this bundle expires. + [Required] + public DateTime ExpiresAt { get; set; } + + /// RSA key size in bits used for this registration (default 4096). + public int KeySize { get; set; } = 4096; + + /// Tags to assign to the agent upon registration completion. + public string[] Tags { get; set; } = []; + + /// + /// Allowed filesystem path prefixes to assign to the agent upon registration completion. + /// Mirrors the pattern. + /// + public string[] AllowedPaths { get; set; } = []; +} diff --git a/src/Werkr.Data/Entities/Schedule/DailyRecurrence.cs b/src/Werkr.Data/Entities/Schedule/DailyRecurrence.cs new file mode 100644 index 0000000..2bd01e3 --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/DailyRecurrence.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// Daily recurrence pattern for a schedule. +/// +[Table( "daily_recurrence" )] +public class DailyRecurrence : ConcurrencyBase { + /// Foreign key to the parent schedule. + [Key] + public Guid ScheduleId { get; set; } + + /// Recur every N days. + public int DayInterval { get; set; } = 1; + + /// Navigation property to the parent schedule. + [ForeignKey( nameof( ScheduleId ) )] + public DbSchedule? Schedule { get; set; } +} diff --git a/src/Werkr.Data/Entities/Schedule/DateTimeInfoBase.cs b/src/Werkr.Data/Entities/Schedule/DateTimeInfoBase.cs new file mode 100644 index 0000000..64ed52c --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/DateTimeInfoBase.cs @@ -0,0 +1,56 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// Abstract base for date/time/timezone composite values. +/// Extends so that entity subclasses +/// (, ) +/// inherit both concurrency tracking and date/time properties. +/// +public abstract class DateTimeInfoBase : ConcurrencyBase { + /// Date component. + [Required] + public DateOnly Date { get; set; } + + /// Time component. + [Required] + public TimeOnly Time { get; set; } + + /// Timezone for the date/time. + [Required] + public TimeZoneInfo TimeZone { get; set; } = TimeZoneInfo.Utc; + + /// Gets the combined DateTime in the specified timezone. + [NotMapped] + public DateTime TzTime => Date.ToDateTime( Time ); + + /// Gets the combined DateTime in UTC. + [NotMapped] + public DateTime UtcTime { + get { + DateTime local = Date.ToDateTime( Time ); + return TimeZoneInfo.ConvertTimeToUtc( local, TimeZone ); + } + } + + /// + /// Converts a UTC DateTime to the entity's configured TimeZone. + /// Used by the scheduling algorithm to convert occurrence times back to local. + /// + /// Thrown when is not . + public DateTime ConvertToTimeZone( DateTime utcDateTime ) + => utcDateTime.Kind != DateTimeKind.Utc + ? throw new ArgumentException( "DateTime must be in UTC.", nameof( utcDateTime ) ) + : TimeZoneInfo.ConvertTimeFromUtc( utcDateTime, TimeZone ); + + /// + /// Converts a DateTime (assumed to be in the entity's TimeZone) to UTC. + /// If the DateTime is already UTC, returns it unchanged. + /// + public DateTime ConvertToUtc( DateTime localDateTime ) + => localDateTime.Kind == DateTimeKind.Utc + ? localDateTime + : TimeZoneInfo.ConvertTimeToUtc( localDateTime, TimeZone ); +} diff --git a/src/Werkr.Data/Entities/Schedule/DbSchedule.cs b/src/Werkr.Data/Entities/Schedule/DbSchedule.cs new file mode 100644 index 0000000..193debf --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/DbSchedule.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Entities.Interfaces; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// Represents a named schedule configuration. +/// +[Table( "schedules" )] +public class DbSchedule : ConcurrencyBase, IKey { + /// Unique identifier. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public Guid Id { get; set; } + + /// Display name of the schedule. + [Required] + [MaxLength( 256 )] + public string Name { get; set; } = string.Empty; + + /// Maximum number of minutes a task should run before being stopped. 0 = no limit. + public long StopTaskAfterMinutes { get; set; } + + /// Navigation property for start date/time info. + public StartDateTimeInfo? StartDateTime { get; set; } + + /// Navigation property for expiration date/time info. + public ExpirationDateTimeInfo? Expiration { get; set; } + + /// Navigation property for repeat options. + public ScheduleRepeatOptions? RepeatOptions { get; set; } + + /// Navigation property for daily recurrence. + public DailyRecurrence? DailyRecurrence { get; set; } + + /// Navigation property for weekly recurrence. + public WeeklyRecurrence? WeeklyRecurrence { get; set; } + + /// Navigation property for monthly recurrence. + public MonthlyRecurrence? MonthlyRecurrence { get; set; } + + /// Link to attached holiday calendar (if any). + public ScheduleHolidayCalendar? HolidayCalendarLink { get; set; } +} diff --git a/src/Werkr.Data/Entities/Schedule/ExpirationDateTimeInfo.cs b/src/Werkr.Data/Entities/Schedule/ExpirationDateTimeInfo.cs new file mode 100644 index 0000000..63c9eaa --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/ExpirationDateTimeInfo.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// Stores the expiration date, time, and timezone for a schedule. +/// Inherits Date, Time, TimeZone, TzTime, UtcTime, and concurrency +/// properties from . +/// +[Table( "schedule_expiration" )] +public class ExpirationDateTimeInfo : DateTimeInfoBase { + /// Foreign key to the parent schedule. + [Key] + public Guid ScheduleId { get; set; } + + /// Navigation property to the parent schedule. + [ForeignKey( nameof( ScheduleId ) )] + public DbSchedule? Schedule { get; set; } +} diff --git a/src/Werkr.Data/Entities/Schedule/HolidayCalendar.cs b/src/Werkr.Data/Entities/Schedule/HolidayCalendar.cs new file mode 100644 index 0000000..e9e1e4d --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/HolidayCalendar.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// A named collection of holiday rules and dates that can be attached to schedules +/// to filter occurrences as an allowlist or blocklist. +/// +[Table( "holiday_calendars" )] +public class HolidayCalendar { + /// Unique identifier. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public Guid Id { get; set; } + + /// Display name of the calendar. + [Required] + [MaxLength( 256 )] + public string Name { get; set; } = string.Empty; + + /// Optional description. + [MaxLength( 1024 )] + public string Description { get; set; } = string.Empty; + + /// Indicates whether this is a system-seeded calendar that cannot be modified or deleted. + public bool IsSystemCalendar { get; set; } + + /// UTC timestamp when the calendar was created. + public DateTime CreatedUtc { get; set; } + + /// UTC timestamp when the calendar was last updated. + public DateTime UpdatedUtc { get; set; } + + // -- Navigation properties -- + + /// Algorithmic rules that generate holiday dates for this calendar. + public ICollection Rules { get; set; } = []; + + /// Materialized and manual holiday dates for this calendar. + public ICollection Dates { get; set; } = []; + + /// Links to schedules that reference this calendar. + public ICollection ScheduleLinks { get; set; } = []; +} diff --git a/src/Werkr.Data/Entities/Schedule/HolidayDate.cs b/src/Werkr.Data/Entities/Schedule/HolidayDate.cs new file mode 100644 index 0000000..11afeea --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/HolidayDate.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// A specific holiday date within a . +/// May be generated by a (materialized cache) or manually entered. +/// +[Table( "holiday_dates" )] +public class HolidayDate { + /// Auto-incrementing primary key. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public long Id { get; set; } + + /// FK to the owning calendar. + public Guid HolidayCalendarId { get; set; } + + /// FK to the rule that generated this date. Null for manually entered dates. + public long? HolidayRuleId { get; set; } + + /// The holiday date. + public DateOnly Date { get; set; } + + /// Display name for this date (e.g., "Independence Day (Observed)"). + [Required] + [MaxLength( 256 )] + public string Name { get; set; } = string.Empty; + + /// The year this date applies to. + public int Year { get; set; } + + /// Optional start of time window for partial-day holidays. + public TimeOnly? WindowStart { get; set; } + + /// Optional end of time window for partial-day holidays. + public TimeOnly? WindowEnd { get; set; } + + /// IANA/Windows timezone ID for the time window. + [MaxLength( 128 )] + public string? WindowTimeZoneId { get; set; } + + /// Whether this date was manually entered (true) or generated by a rule (false). Decision H16. + [NotMapped] + public bool IsManual => HolidayRuleId is null; + + // -- Navigation properties -- + + /// The calendar this date belongs to. + public HolidayCalendar Calendar { get; set; } = null!; + + /// The rule that generated this date, or null for manual entries. + public HolidayRule? GeneratedByRule { get; set; } +} diff --git a/src/Werkr.Data/Entities/Schedule/HolidayRule.cs b/src/Werkr.Data/Entities/Schedule/HolidayRule.cs new file mode 100644 index 0000000..d0b4355 --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/HolidayRule.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Calendar.Enums; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// An algorithmic rule that computes holiday dates for a . +/// +[Table( "holiday_rules" )] +public class HolidayRule { + /// Auto-incrementing primary key. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public long Id { get; set; } + + /// FK to the owning calendar. + public Guid HolidayCalendarId { get; set; } + + /// Display name (e.g., "New Year's Day"). + [Required] + [MaxLength( 256 )] + public string Name { get; set; } = string.Empty; + + /// The type of rule (FixedDate, NthWeekdayOfMonth, LastWeekdayOfMonth). + public HolidayRuleType RuleType { get; set; } + + /// Month (1–12). Required for all rule types. Uses int? not the Month enum (Decision H20). + public int? Month { get; set; } + + /// Day of month (1–31). Required for FixedDate rules. + public int? Day { get; set; } + + /// Day of week. Required for NthWeekdayOfMonth and LastWeekdayOfMonth. + public DayOfWeek? DayOfWeek { get; set; } + + /// Week number within the month (1–5). Required for NthWeekdayOfMonth. + public int? WeekNumber { get; set; } + + /// Optional start of time window for partial-day holidays. + public TimeOnly? WindowStart { get; set; } + + /// Optional end of time window for partial-day holidays. + public TimeOnly? WindowEnd { get; set; } + + /// IANA/Windows timezone ID for the time window. Required if WindowStart/WindowEnd are set. + [MaxLength( 128 )] + public string? WindowTimeZoneId { get; set; } + + /// How the holiday is observed when it falls on a weekend. + public ObservanceRule ObservanceRule { get; set; } + + /// Earliest year this rule applies (inclusive). Null means no lower bound. + public int? YearStart { get; set; } + + /// Latest year this rule applies (inclusive). Null means no upper bound. + public int? YearEnd { get; set; } + + // -- Navigation properties -- + + /// The calendar this rule belongs to. + public HolidayCalendar Calendar { get; set; } = null!; + + /// Dates generated by this rule (materialized cache). + public ICollection GeneratedDates { get; set; } = []; +} diff --git a/src/Werkr.Data/Entities/Schedule/MonthlyRecurrence.cs b/src/Werkr.Data/Entities/Schedule/MonthlyRecurrence.cs new file mode 100644 index 0000000..c4d5364 --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/MonthlyRecurrence.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Calendar.Enums; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// Monthly recurrence pattern for a schedule. +/// Supports two modes: +/// 1. Day-of-month: specifies specific days (1-31, negative for end-of-month offset). +/// 2. Week-based: + specifies e.g. "2nd Tuesday". +/// controls which months the pattern applies to in both modes. +/// +[Table( "monthly_recurrence" )] +public class MonthlyRecurrence : ConcurrencyBase { + /// Foreign key to the parent schedule. + [Key] + public Guid ScheduleId { get; set; } + + /// + /// Specific days of the month (1-31) to recur on. + /// Use negative values for counting from end of month (-1 = last day). + /// Null when using week-based recurrence mode. + /// Stored as JSON in the database. + /// + public int[]? DayNumbers { get; set; } + + /// Flags indicating which months to recur in. + public MonthsOfYear MonthsOfYear { get; set; } + + /// + /// Flags indicating which weeks within the month to recur (for week-based monthly schedules). + /// Null when using day-of-month recurrence mode. + /// + public WeekNumberWithinMonth? WeekNumber { get; set; } + + /// + /// Days of week (for week-based monthly schedules). + /// Null when using day-of-month recurrence mode. + /// + public DaysOfWeek? DaysOfWeek { get; set; } + + /// Navigation property to the parent schedule. + [ForeignKey( nameof( ScheduleId ) )] + public DbSchedule? Schedule { get; set; } +} diff --git a/src/Werkr.Data/Entities/Schedule/ScheduleAuditLog.cs b/src/Werkr.Data/Entities/Schedule/ScheduleAuditLog.cs new file mode 100644 index 0000000..2849896 --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/ScheduleAuditLog.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Calendar.Enums; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// Records a schedule occurrence that was suppressed (or required) by a holiday calendar filter. +/// All filtered occurrences are logged for audit visibility. (Decision H10) +/// +[Table( "schedule_audit_log" )] +public class ScheduleAuditLog { + /// Auto-incrementing primary key. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public long Id { get; set; } + + /// FK to the schedule whose occurrence was filtered. + public Guid ScheduleId { get; set; } + + /// The UTC time of the occurrence that was filtered. + public DateTime OccurrenceUtcTime { get; set; } + + /// Name of the holiday calendar that caused the filter. + [Required] + [MaxLength( 256 )] + public string CalendarName { get; set; } = string.Empty; + + /// Name of the specific holiday that matched. + [Required] + [MaxLength( 256 )] + public string HolidayName { get; set; } = string.Empty; + + /// Whether the calendar was operating as a blocklist or allowlist. + public HolidayCalendarMode Mode { get; set; } + + /// UTC timestamp when this audit record was created. + public DateTime CreatedUtc { get; set; } + + // -- Navigation properties -- + + /// The schedule this audit record belongs to. + public DbSchedule Schedule { get; set; } = null!; +} diff --git a/src/Werkr.Data/Entities/Schedule/ScheduleHolidayCalendar.cs b/src/Werkr.Data/Entities/Schedule/ScheduleHolidayCalendar.cs new file mode 100644 index 0000000..4d3adfb --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/ScheduleHolidayCalendar.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Calendar.Enums; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// Junction entity linking a to a +/// with a mode (allowlist or blocklist). Enforces one calendar per schedule via unique index. (Decision H5, H6) +/// +[Table( "schedule_holiday_calendars" )] +public class ScheduleHolidayCalendar { + /// FK to the schedule. + public Guid ScheduleId { get; set; } + + /// FK to the holiday calendar. + public Guid HolidayCalendarId { get; set; } + + /// Whether the calendar acts as a blocklist or allowlist for this schedule. + public HolidayCalendarMode Mode { get; set; } + + // -- Navigation properties -- + + /// The schedule this link belongs to. + public DbSchedule Schedule { get; set; } = null!; + + /// The holiday calendar this link references. + public HolidayCalendar Calendar { get; set; } = null!; +} diff --git a/src/Werkr.Data/Entities/Schedule/ScheduleRepeatOptions.cs b/src/Werkr.Data/Entities/Schedule/ScheduleRepeatOptions.cs new file mode 100644 index 0000000..e673d5b --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/ScheduleRepeatOptions.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// Repeat options for a schedule (interval-based repetition). +/// +[Table( "schedule_repeat_options" )] +public class ScheduleRepeatOptions : ConcurrencyBase { + /// Foreign key to the parent schedule. + [Key] + public Guid ScheduleId { get; set; } + + /// Interval in minutes between repeat executions. + public int RepeatIntervalMinutes { get; set; } + + /// Total duration in minutes for which repeats should occur. + public int RepeatDurationMinutes { get; set; } + + /// Navigation property to the parent schedule. + [ForeignKey( nameof( ScheduleId ) )] + public DbSchedule? Schedule { get; set; } +} diff --git a/src/Werkr.Data/Entities/Schedule/StartDateTimeInfo.cs b/src/Werkr.Data/Entities/Schedule/StartDateTimeInfo.cs new file mode 100644 index 0000000..3fbd1f0 --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/StartDateTimeInfo.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// Stores the start date, time, and timezone for a schedule. +/// Inherits Date, Time, TimeZone, TzTime, UtcTime, and concurrency +/// properties from . +/// +[Table( "schedule_start_datetimeinfo" )] +public class StartDateTimeInfo : DateTimeInfoBase { + /// Foreign key to the parent schedule. + [Key] + public Guid ScheduleId { get; set; } + + /// Navigation property to the parent schedule. + [ForeignKey( nameof( ScheduleId ) )] + public DbSchedule? Schedule { get; set; } +} diff --git a/src/Werkr.Data/Entities/Schedule/WeeklyRecurrence.cs b/src/Werkr.Data/Entities/Schedule/WeeklyRecurrence.cs new file mode 100644 index 0000000..a0b196f --- /dev/null +++ b/src/Werkr.Data/Entities/Schedule/WeeklyRecurrence.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Calendar.Enums; + +namespace Werkr.Data.Entities.Schedule; + +/// +/// Weekly recurrence pattern for a schedule. +/// +[Table( "weekly_recurrence" )] +public class WeeklyRecurrence : ConcurrencyBase { + /// Foreign key to the parent schedule. + [Key] + public Guid ScheduleId { get; set; } + + /// Recur every N weeks. + public int WeekInterval { get; set; } = 1; + + /// Flags indicating which days of the week to recur on. + public DaysOfWeek DaysOfWeek { get; set; } + + /// Navigation property to the parent schedule. + [ForeignKey( nameof( ScheduleId ) )] + public DbSchedule? Schedule { get; set; } +} diff --git a/src/Werkr.Data/Entities/Settings/ConfigurationSettings.cs b/src/Werkr.Data/Entities/Settings/ConfigurationSettings.cs new file mode 100644 index 0000000..b86ab6a --- /dev/null +++ b/src/Werkr.Data/Entities/Settings/ConfigurationSettings.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Entities.Interfaces; + +namespace Werkr.Data.Entities.Settings; + +/// +/// Global application configuration stored in the database. +/// Exactly one row exists; seeded on first startup. +/// +[Table( "config_settings" )] +public class ConfigurationSettings : ConcurrencyBase, IKey { + /// Unique identifier. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public Guid Id { get; set; } + + /// Default RSA key size in bits. + public int DefaultKeySize { get; set; } = 4096; + + // ── Server identity ────────────────────────────────────────────── + /// Display name shown in the Blazor UI header. + [MaxLength( 200 )] + public string ServerName { get; set; } = "Werkr Server"; + + /// Whether new agent registrations are accepted. + public bool AllowRegistration { get; set; } = true; + + // ── UI polling ─────────────────────────────────────────────────── + /// Seconds between dashboard / list auto-refresh polls. + public int PollingIntervalSeconds { get; set; } = 30; + + /// Seconds between run-detail / workflow-run auto-refresh polls. + public int RunDetailPollingIntervalSeconds { get; set; } = 15; + +} diff --git a/src/Werkr.Data/Entities/Tasks/ErrorCategory.cs b/src/Werkr.Data/Entities/Tasks/ErrorCategory.cs new file mode 100644 index 0000000..a42ba6b --- /dev/null +++ b/src/Werkr.Data/Entities/Tasks/ErrorCategory.cs @@ -0,0 +1,26 @@ +namespace Werkr.Data.Entities.Tasks; + +/// +/// Categorizes the type of error that occurred during job execution. +/// Designed for expansion — only , , +/// and are populated in Phase 5. +/// +public enum ErrorCategory { + /// No error — job completed normally. + None = 0, + + /// Job exceeded its configured timeout. + Timeout = 1, + + /// Agent was unreachable (gRPC transport failure, DNS, timeout). + AgentUnreachable = 2, + + /// Script or command execution error (non-zero exit, exception). + ScriptError = 3, + + /// Insufficient permissions to execute the action. + PermissionDenied = 4, + + /// An unknown or uncategorized error occurred. + Unknown = 99, +} diff --git a/src/Werkr.Data/Entities/Tasks/TaskActionType.cs b/src/Werkr.Data/Entities/Tasks/TaskActionType.cs new file mode 100644 index 0000000..3f9a30f --- /dev/null +++ b/src/Werkr.Data/Entities/Tasks/TaskActionType.cs @@ -0,0 +1,17 @@ +namespace Werkr.Data.Entities.Tasks; + +/// +/// Actions that can be performed by a task. +/// +public enum TaskActionType { + /// Execute a PowerShell command. + PowerShellCommand = 0, + /// Execute a PowerShell script. + PowerShellScript = 1, + /// Execute a system shell command. + ShellCommand = 2, + /// Execute a system shell script. + ShellScript = 3, + /// Built-in file/process action. + Action = 4, +} diff --git a/src/Werkr.Data/Entities/Tasks/WerkrJob.cs b/src/Werkr.Data/Entities/Tasks/WerkrJob.cs new file mode 100644 index 0000000..f3b34a0 --- /dev/null +++ b/src/Werkr.Data/Entities/Tasks/WerkrJob.cs @@ -0,0 +1,75 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Entities.Interfaces; +using Werkr.Data.Entities.Registration; + +namespace Werkr.Data.Entities.Tasks; + +/// +/// Represents a runtime job instance created from a task. +/// +[Table( "jobs" )] +public class WerkrJob : ConcurrencyBase, IKey { + /// Unique identifier. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public Guid Id { get; set; } + + /// Foreign key to the source task. + public long TaskId { get; set; } + + /// Snapshot of the task content at job creation time. + [MaxLength( 8000 )] + public string TaskSnapshot { get; set; } = string.Empty; + + /// How long the job ran in seconds. + public double RuntimeSeconds { get; set; } + + /// When the job started (UTC). + public DateTime StartTime { get; set; } + + /// When the job finished (UTC). + public DateTime? EndTime { get; set; } + + /// Whether the job completed successfully. + public bool Success { get; set; } + + /// Foreign key to the agent that executed this job. + public Guid? AgentConnectionId { get; set; } + + /// The process/shell exit code, if applicable. + public int? ExitCode { get; set; } + + /// Categorized error type when the job fails. + public ErrorCategory ErrorCategory { get; set; } = ErrorCategory.None; + + /// + /// Truncated tail preview of the job output (last ~2000 characters). + /// Full output is stored on disk at . + /// + [MaxLength( 2000 )] + public string? Output { get; set; } + + /// + /// Relative path to the full output log file on disk. + /// Format: {JobId}.log under the configured job output directory. + /// + [MaxLength( 512 )] + public string? OutputPath { get; set; } + + /// Foreign key to the workflow run, if this job was created as part of a workflow. + public Guid? WorkflowRunId { get; set; } + + /// Navigation property to the source task. + [ForeignKey( nameof( TaskId ) )] + public WerkrTask? Task { get; set; } + + /// Navigation property to the agent that executed this job. + [ForeignKey( nameof( AgentConnectionId ) )] + public RegisteredConnection? AgentConnection { get; set; } + + /// Navigation property to the workflow run. + [ForeignKey( nameof( WorkflowRunId ) )] + public Workflows.WorkflowRun? WorkflowRun { get; set; } +} diff --git a/src/Werkr.Data/Entities/Tasks/WerkrTask.cs b/src/Werkr.Data/Entities/Tasks/WerkrTask.cs new file mode 100644 index 0000000..1e6101b --- /dev/null +++ b/src/Werkr.Data/Entities/Tasks/WerkrTask.cs @@ -0,0 +1,96 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Entities.Interfaces; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Data.Entities.Tasks; + +/// +/// Represents a single executable task. +/// +[Table( "tasks" )] +public class WerkrTask : ConcurrencyBase, IKey { + /// Unique identifier. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public long Id { get; set; } + + /// Display name. + [Required] + [MaxLength( 256 )] + public string Name { get; set; } = string.Empty; + + /// Human-readable description of the task. + [MaxLength( 2000 )] + public string Description { get; set; } = string.Empty; + + /// The type of action this task performs. + public TaskActionType ActionType { get; set; } + + /// Foreign key to the schedule, if scheduled. + public Guid? ScheduleId { get; set; } + + /// Foreign key to the parent workflow, if part of one. + public long? WorkflowId { get; set; } + + /// The command or script content to execute. + [MaxLength( 8000 )] + public string Content { get; set; } = string.Empty; + + /// + /// Optional arguments for script-type tasks. + /// Passed to ExecuteScriptAsync when + /// is or . + /// Stored as a JSON column. + /// + public string[]? Arguments { get; set; } + + /// + /// Tags for agent targeting. An agent is selected when any of its + /// Tags matches any of these target tags (case-insensitive). + /// Stored as a JSON column. + /// + public string[] TargetTags { get; set; } = []; + + /// Whether this task is enabled for scheduled execution. + public bool Enabled { get; set; } = true; + + /// + /// Maximum minutes the task may run before being cancelled. + /// Null defaults to 30 minutes in JobExecutionService. + /// + public long? TimeoutMinutes { get; set; } + + /// + /// Agent schedule-sync interval in minutes. Randomized between 30–60 + /// at task creation time and fixed thereafter. + /// + public int SyncIntervalMinutes { get; set; } + + /// + /// Optional expression evaluated against the operator result + /// to determine if a job succeeded. When null, defaults are inferred from . + /// + [MaxLength( 500 )] + public string? SuccessCriteria { get; set; } + + /// + /// The specific built-in action name (e.g. "CopyFile", "StartProcess"). + /// Required when is ; + /// must be null otherwise. + /// + [MaxLength( 30 )] + public string? ActionSubType { get; set; } + + /// + /// JSON-serialized parameters for the built-in action. + /// Required when is ; + /// must be null otherwise. No max length — WriteContent payloads may be large. + /// + public string? ActionParameters { get; set; } + + /// Navigation property for parent workflow. + [ForeignKey( nameof( WorkflowId ) )] + public Workflow? Workflow { get; set; } +} diff --git a/src/Werkr.Data/Entities/Workflows/ControlStatement.cs b/src/Werkr.Data/Entities/Workflows/ControlStatement.cs new file mode 100644 index 0000000..2b09fc9 --- /dev/null +++ b/src/Werkr.Data/Entities/Workflows/ControlStatement.cs @@ -0,0 +1,19 @@ +namespace Werkr.Data.Entities.Workflows; + +/// +/// Control flow statement type for workflow steps. +/// +public enum ControlStatement { + /// Execute sequentially after dependencies complete. + Sequential = 0, + /// Execute only if condition is true. + If = 1, + /// Execute only if prior If/ElseIf was false. + Else = 2, + /// Execute if prior If/ElseIf was false AND this condition is true. + ElseIf = 3, + /// Repeat while condition is true (evaluated before each iteration). + While = 4, + /// Execute once, then repeat while condition is true. + Do = 5, +} diff --git a/src/Werkr.Data/Entities/Workflows/DependencyMode.cs b/src/Werkr.Data/Entities/Workflows/DependencyMode.cs new file mode 100644 index 0000000..45a7fc4 --- /dev/null +++ b/src/Werkr.Data/Entities/Workflows/DependencyMode.cs @@ -0,0 +1,11 @@ +namespace Werkr.Data.Entities.Workflows; + +/// +/// Defines how a workflow step evaluates its predecessor dependencies. +/// +public enum DependencyMode { + /// All predecessor steps must complete and satisfy conditions before this step executes. + All = 0, + /// Any single predecessor completing and satisfying conditions triggers this step. + Any = 1, +} diff --git a/src/Werkr.Data/Entities/Workflows/Workflow.cs b/src/Werkr.Data/Entities/Workflows/Workflow.cs new file mode 100644 index 0000000..a9ec7fa --- /dev/null +++ b/src/Werkr.Data/Entities/Workflows/Workflow.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Entities.Interfaces; +using Werkr.Data.Entities.Schedule; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Data.Entities.Workflows; + +/// +/// Represents a workflow containing ordered tasks. +/// +[Table( "workflows" )] +public class Workflow : ConcurrencyBase, IKey { + /// Unique identifier. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public long Id { get; set; } + + /// Display name of the workflow. + [Required] + [MaxLength( 256 )] + public string Name { get; set; } = string.Empty; + + /// Description of the workflow. + [MaxLength( 2000 )] + public string Description { get; set; } = string.Empty; + + /// Whether the workflow is enabled. + public bool Enabled { get; set; } = true; + + /// Foreign key to schedule for automated workflow execution. + public Guid? ScheduleId { get; set; } + + /// Navigation to the schedule. + [ForeignKey( nameof( ScheduleId ) )] + public DbSchedule? Schedule { get; set; } + + /// Navigation property for workflow steps. + public ICollection Steps { get; set; } = []; + + /// Navigation property for tasks in this workflow. + public ICollection Tasks { get; set; } = []; + + /// Navigation property for workflow runs. + public ICollection Runs { get; set; } = []; +} diff --git a/src/Werkr.Data/Entities/Workflows/WorkflowRun.cs b/src/Werkr.Data/Entities/Workflows/WorkflowRun.cs new file mode 100644 index 0000000..7a10fa6 --- /dev/null +++ b/src/Werkr.Data/Entities/Workflows/WorkflowRun.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Entities.Interfaces; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Data.Entities.Workflows; + +/// +/// Represents a single execution run of a workflow. +/// +[Table( "workflow_runs" )] +public class WorkflowRun : ConcurrencyBase, IKey { + /// Unique identifier. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public Guid Id { get; set; } + + /// Foreign key to the parent workflow. + public long WorkflowId { get; set; } + + /// When the workflow run started (UTC). + public DateTime StartTime { get; set; } + + /// When the workflow run ended (UTC). Null if still running. + public DateTime? EndTime { get; set; } + + /// Current status of the workflow run. + public WorkflowRunStatus Status { get; set; } = WorkflowRunStatus.Running; + + /// Navigation to the parent workflow. + [ForeignKey( nameof( WorkflowId ) )] + public Workflow? Workflow { get; set; } + + /// Navigation to jobs created during this workflow run. + public ICollection Jobs { get; set; } = []; +} diff --git a/src/Werkr.Data/Entities/Workflows/WorkflowRunStatus.cs b/src/Werkr.Data/Entities/Workflows/WorkflowRunStatus.cs new file mode 100644 index 0000000..1f9c0fa --- /dev/null +++ b/src/Werkr.Data/Entities/Workflows/WorkflowRunStatus.cs @@ -0,0 +1,15 @@ +namespace Werkr.Data.Entities.Workflows; + +/// +/// Status of a workflow execution run. +/// +public enum WorkflowRunStatus { + /// Workflow is currently executing. + Running = 0, + /// All steps completed successfully. + Completed = 1, + /// One or more steps failed. + Failed = 2, + /// Workflow execution was cancelled. + Cancelled = 3, +} diff --git a/src/Werkr.Data/Entities/Workflows/WorkflowStep.cs b/src/Werkr.Data/Entities/Workflows/WorkflowStep.cs new file mode 100644 index 0000000..ec3c0c7 --- /dev/null +++ b/src/Werkr.Data/Entities/Workflows/WorkflowStep.cs @@ -0,0 +1,78 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Werkr.Data.Entities.Interfaces; +using Werkr.Data.Entities.Registration; +using Werkr.Data.Entities.Tasks; + +namespace Werkr.Data.Entities.Workflows; + +/// +/// Represents a step within a workflow, defining execution order, dependencies, +/// and optional control flow (If/Else/ElseIf/While/Do). +/// +[Table( "workflow_steps" )] +public class WorkflowStep : ConcurrencyBase, IKey { + /// Unique identifier. + [Key] + [DatabaseGenerated( DatabaseGeneratedOption.Identity )] + public long Id { get; set; } + + /// Foreign key to the parent workflow. + public long WorkflowId { get; set; } + + /// Foreign key to the task for this step. + public long TaskId { get; set; } + + /// Execution order within the workflow (lower = earlier). Used as tiebreaker within topological levels. + public int Order { get; set; } + + /// Control flow statement type for this step. + public ControlStatement ControlStatement { get; set; } = ControlStatement.Sequential; + + /// + /// Condition expression evaluated against prior step results. + /// Supports: $exitCode == 0, $exitCode != 0, $exitCode > N, + /// $? -eq $true, $? -eq $false, and custom expressions. + /// Null/empty = always true (for Sequential steps). + /// + [MaxLength( 2000 )] + public string? ConditionExpression { get; set; } + + /// + /// Maximum iterations for While/Do loops. Safety guard to prevent infinite loops. + /// Default: 100. Only applicable when ControlStatement is While or Do. + /// + public int MaxIterations { get; set; } = 100; + + /// + /// Optional: pin this step to a specific agent, bypassing TargetTags resolution. + /// If null, the agent is resolved at runtime via the step's task TargetTags. + /// + public Guid? AgentConnectionIdOverride { get; set; } + + /// + /// How this step evaluates its predecessor dependencies. + /// All = all predecessors must satisfy the step's condition (default). + /// Any = at least one predecessor satisfying the condition triggers execution. + /// + public DependencyMode DependencyMode { get; set; } = DependencyMode.All; + + /// Navigation property to the parent workflow. + [ForeignKey( nameof( WorkflowId ) )] + public Workflow? Workflow { get; set; } + + /// Navigation to the task associated with this step. Required for eager loading in WorkflowExecutor. + [ForeignKey( nameof( TaskId ) )] + public WerkrTask? Task { get; set; } + + /// Navigation to the overridden agent connection. + [ForeignKey( nameof( AgentConnectionIdOverride ) )] + public RegisteredConnection? AgentConnectionOverride { get; set; } + + /// Navigation to dependency relationships where this step is the dependent. + public ICollection Dependencies { get; set; } = []; + + /// Navigation to dependency relationships where this step is a predecessor. + public ICollection Dependents { get; set; } = []; +} diff --git a/src/Werkr.Data/Entities/Workflows/WorkflowStepDependency.cs b/src/Werkr.Data/Entities/Workflows/WorkflowStepDependency.cs new file mode 100644 index 0000000..75c3d22 --- /dev/null +++ b/src/Werkr.Data/Entities/Workflows/WorkflowStepDependency.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Werkr.Data.Entities.Workflows; + +/// +/// Join entity representing a dependency relationship between two workflow steps. +/// The step identified by depends on the step identified by . +/// +[Table( "workflow_step_dependencies" )] +public class WorkflowStepDependency { + /// The step that has the dependency (the dependent). + public long StepId { get; set; } + + /// The step that must complete first (the predecessor). + public long DependsOnStepId { get; set; } + + /// Navigation to the dependent step. + [ForeignKey( nameof( StepId ) )] + public WorkflowStep? Step { get; set; } + + /// Navigation to the predecessor step. + [ForeignKey( nameof( DependsOnStepId ) )] + public WorkflowStep? DependsOnStep { get; set; } +} diff --git a/src/Werkr.Data/PostgresDesignTimeFactory.cs b/src/Werkr.Data/PostgresDesignTimeFactory.cs new file mode 100644 index 0000000..97cef81 --- /dev/null +++ b/src/Werkr.Data/PostgresDesignTimeFactory.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Werkr.Data; + +/// +/// Design-time factory for , used by dotnet ef CLI +/// when --context PostgresWerkrDbContext is specified. +/// +public class PostgresDesignTimeFactory : IDesignTimeDbContextFactory { + /// + public PostgresWerkrDbContext CreateDbContext( string[] args ) { + DbContextOptionsBuilder optionsBuilder = new( ); + _ = optionsBuilder.UseNpgsql( "Host=localhost;Database=werkr_design;Username=postgres;Password=postgres", npgsql => + npgsql.MigrationsHistoryTable( "__EFMigrationsHistory", "werkr" ) ) + .UseSnakeCaseNamingConvention( ); + return new PostgresWerkrDbContext( optionsBuilder.Options ); + } +} diff --git a/src/Werkr.Data/PostgresWerkrDbContext.cs b/src/Werkr.Data/PostgresWerkrDbContext.cs new file mode 100644 index 0000000..d4fea9c --- /dev/null +++ b/src/Werkr.Data/PostgresWerkrDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + +namespace Werkr.Data; + +/// +/// Postgres-specific used for migration generation and runtime resolution. +/// EF Core requires a distinct type per provider so each set of migrations gets its own +/// . +/// +/// Creates a new Postgres-targeted instance. +/// The Postgres-configured options. +public class PostgresWerkrDbContext( DbContextOptions options ) + : WerkrDbContext( options ) { } diff --git a/src/Werkr.Data/Ranges/IRange.cs b/src/Werkr.Data/Ranges/IRange.cs new file mode 100644 index 0000000..96468c3 --- /dev/null +++ b/src/Werkr.Data/Ranges/IRange.cs @@ -0,0 +1,13 @@ +namespace Werkr.Data.Ranges; + +/// +/// Defines a range with a start and end value. +/// +/// The value type of the range bounds. +public interface IRange where T : struct { + /// The start of the range. + T Start { get; } + + /// The end of the range. + T End { get; } +} diff --git a/src/Werkr.Data/Ranges/IntRange.cs b/src/Werkr.Data/Ranges/IntRange.cs new file mode 100644 index 0000000..eacf678 --- /dev/null +++ b/src/Werkr.Data/Ranges/IntRange.cs @@ -0,0 +1,94 @@ +namespace Werkr.Data.Ranges; + +/// +/// Represents a contiguous range of integers. Used for formatting +/// day-of-month lists and other numeric sequences into human-readable descriptions +/// (e.g., "1–5, 15, 20–25"). +/// +public class IntRange : IRange { + private const long DefaultStartValue = (long)int.MinValue - 1; + private const long DefaultEndValue = (long)int.MaxValue + 1; + + private long _start = DefaultStartValue; + private long _end = DefaultEndValue; + + /// + public int Start => _start == DefaultStartValue ? int.MinValue : (int)_start; + + /// + public int End => _end == DefaultEndValue ? int.MaxValue : (int)_end; + + /// Initializes a new range with default (sentinel) values. + public IntRange( ) { + _start = DefaultStartValue; + _end = DefaultEndValue; + } + + /// Initializes a range with explicit start and end values. + /// The inclusive start of the range. + /// The inclusive end of the range. + public IntRange( int start, int end ) { + _start = start; + _end = end; + } + + /// Sets the start value of this range. + public void SetStart( int start ) => _start = start; + + /// Sets the end value of this range. + public void SetEnd( int end ) => _end = end; + + /// + public override string ToString( ) => + _start == _end + ? _start.ToString( ) + : $"{_start} - {_end}"; + + /// + /// Formats a collection of instances as a comma-separated string, + /// ordered by start value. + /// + public static string ToString( IEnumerable ranges ) + => string.Join( + ", ", + ranges + .OrderBy( range => range._start ) + .Select( range => range.ToString( ) ) ); + + /// + /// Converts a sequence of integers into the minimum set of contiguous instances. + /// + /// The integers to group into ranges. + /// An enumerable of contiguous ranges. + public static IEnumerable GetContiguousRanges( IEnumerable intRange ) { + List result = []; + IntRange range = new( ); + long index = 0; + int[] longRange = [.. intRange.Order( ).Distinct( )]; + + for (long i = longRange[0]; i <= longRange.Last( ); i++) { + if (range._start == DefaultStartValue) { + if (longRange[index] != i) { + i = longRange[index]; + } + range.SetStart( (int)i ); + } + + long nextIndex = index + 1 < longRange.LongLength + ? longRange[index + 1] + : i - 1; + + if ( + longRange[index] == i && + nextIndex != i + 1 && + range._end == DefaultEndValue + ) { + range.SetEnd( (int)i ); + result.Add( range ); + range = new( ); + } + index++; + } + return result; + } +} diff --git a/src/Werkr.Data/Ranges/RangeOfDays.cs b/src/Werkr.Data/Ranges/RangeOfDays.cs new file mode 100644 index 0000000..e84d4f5 --- /dev/null +++ b/src/Werkr.Data/Ranges/RangeOfDays.cs @@ -0,0 +1,75 @@ +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Extensions; + +namespace Werkr.Data.Ranges; + +/// +/// Produces contiguous range descriptions for flags +/// (e.g., "Mon–Wed, Fri"). +/// +public class RangeOfDays : IRange { + + private int _start; + private int _end = 6; + + /// + public DayOfWeek Start => (DayOfWeek)_start; + + /// + public DayOfWeek End => (DayOfWeek)_end; + + /// Sets the start day index. + public void SetStart( int start ) => _start = start; + + /// Sets the end day index. + public void SetEnd( int end ) => _end = end; + + /// + public override string ToString( ) => + _start == _end + ? Start.ToString( true ) + : $"{Start.ToString( true )} - {End.ToString( true )}"; + + /// + /// Formats this range as a string using either abbreviated or full day names. + /// + /// + /// When true, uses abbreviations (e.g., "Mon"). + /// When false, uses full names (e.g., "Monday"). + /// + public string ToString( bool abbreviated = false ) => + _start == _end + ? Start.ToString( abbreviated ) + : $"{Start.ToString( abbreviated )} - {End.ToString( abbreviated )}"; + + /// + /// Formats a collection of as a comma-separated string. + /// + public static string ToString( IEnumerable ranges, bool abbreviated = false ) + => string.Join( + ", ", + ranges + .OrderBy( item => item._start ) + .Select( item => item.ToString( abbreviated ) ) ); + + /// + /// Converts a list of values into contiguous ranges. + /// + public static IEnumerable GetContiguousRanges( List days ) { + List ranges = []; + RangeOfDays range = new( ); + foreach (IntRange intRange in IntRange.GetContiguousRanges( days.Select( day => (int)day ) )) { + range.SetStart( intRange.Start ); + range.SetEnd( intRange.End ); + ranges.Add( range ); + range = new( ); + } + return ranges; + } + + /// + /// Converts flags into contiguous ranges. + /// + public static IEnumerable GetContiguousRanges( DaysOfWeek days ) + => GetContiguousRanges( days.GetDaysOfWeek( ) ); +} diff --git a/src/Werkr.Data/Ranges/RangeOfMonths.cs b/src/Werkr.Data/Ranges/RangeOfMonths.cs new file mode 100644 index 0000000..1962f58 --- /dev/null +++ b/src/Werkr.Data/Ranges/RangeOfMonths.cs @@ -0,0 +1,74 @@ +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Extensions; + +namespace Werkr.Data.Ranges; + +/// +/// Produces contiguous range descriptions for flags +/// (e.g., "Jan–Mar, Jun"). +/// +public class RangeOfMonths : IRange { + private int _start = 1; + private int _end = 12; + + /// + public Month Start => (Month)_start; + + /// + public Month End => (Month)_end; + + /// Sets the start month index. + public void SetStart( int start ) => _start = start; + + /// Sets the end month index. + public void SetEnd( int end ) => _end = end; + + /// + public override string ToString( ) => + _start == _end + ? Start.ToString( true ) + : $"{Start.ToString( true )} - {End.ToString( true )}"; + + /// + /// Formats this range as a string using either abbreviated or full month names. + /// + /// + /// When true, uses abbreviations (e.g., "Jan"). + /// When false, uses full names (e.g., "January"). + /// + public string ToString( bool abbreviated = false ) => + _start == _end + ? Start.ToString( abbreviated ) + : $"{Start.ToString( abbreviated )} - {End.ToString( abbreviated )}"; + + /// + /// Formats a collection of as a comma-separated string. + /// + public static string ToString( IEnumerable ranges, bool abbreviated = false ) + => string.Join( + ", ", + ranges + .OrderBy( item => item._start ) + .Select( item => item.ToString( abbreviated ) ) ); + + /// + /// Converts a list of values into contiguous ranges. + /// + public static IEnumerable GetContiguousRanges( List months ) { + List ranges = []; + RangeOfMonths range = new( ); + foreach (IntRange monthRange in IntRange.GetContiguousRanges( months.GetIntMonths( ) )) { + range.SetStart( monthRange.Start ); + range.SetEnd( monthRange.End ); + ranges.Add( range ); + range = new( ); + } + return ranges; + } + + /// + /// Converts flags into contiguous ranges. + /// + public static IEnumerable GetContiguousRanges( MonthsOfYear months ) + => GetContiguousRanges( months.GetMonths( ) ); +} diff --git a/src/Werkr.Data/Ranges/RangeOfWeekNums.cs b/src/Werkr.Data/Ranges/RangeOfWeekNums.cs new file mode 100644 index 0000000..25c2c00 --- /dev/null +++ b/src/Werkr.Data/Ranges/RangeOfWeekNums.cs @@ -0,0 +1,106 @@ +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Extensions; + +namespace Werkr.Data.Ranges; + +/// +/// Produces contiguous range descriptions for flags +/// (e.g., "1st–3rd, 5th"). +/// +public class RangeOfWeekNums : IRange { + + private int _start; + private int _end; + + /// + public WeekNumberWithinMonth Start => (WeekNumberWithinMonth)_start; + + /// + public WeekNumberWithinMonth End => (WeekNumberWithinMonth)_end; + + /// Sets the start week number index. + public void SetStart( int start ) => _start = start; + + /// Sets the end week number index. + public void SetEnd( int end ) => _end = end; + + /// + public override string ToString( ) => + _start == _end + ? Start.ToString( ) + : $"{Start} - {End}"; + + /// + /// Formats this range as a string. + /// + /// + /// When true, uses numeric representation (e.g., "1"). + /// When false, uses ordinal names (e.g., "First"). + /// + public string ToString( bool abbreviated = false ) { + string start = Start.ToString( ); + string end = End.ToString( ); + if (abbreviated) { + start = GetWeekNum( Start ).ToString( ); + end = GetWeekNum( End ).ToString( ); + } + return _start == _end + ? start + : $"{start} - {end}"; + } + + /// + /// Formats a collection of as a comma-separated string. + /// + public static string ToString( IEnumerable ranges, bool abbreviated = false ) + => string.Join( + ", ", + ranges + .OrderBy( item => item._start ) + .Select( item => item.ToString( abbreviated ) ) ); + + /// + /// Converts flags into contiguous ranges. + /// + public static IEnumerable GetContiguousRanges( WeekNumberWithinMonth weekNums ) { + List ranges = []; + RangeOfWeekNums range = new( ); + foreach (IntRange weekRange in IntRange.GetContiguousRanges( weekNums.GetWeekNumbersInMonth( ) )) { + range.SetStart( GetWeekNum( weekRange.Start ) ); + range.SetEnd( GetWeekNum( weekRange.End ) ); + ranges.Add( range ); + range = new( ); + } + return ranges; + } + + /// + /// Converts a 1-based week number integer to the corresponding + /// flag value. + /// + internal static int GetWeekNum( int weekNumberWithinMonth ) + => weekNumberWithinMonth switch { + 1 => (int)WeekNumberWithinMonth.First, + 2 => (int)WeekNumberWithinMonth.Second, + 3 => (int)WeekNumberWithinMonth.Third, + 4 => (int)WeekNumberWithinMonth.Fourth, + 5 => (int)WeekNumberWithinMonth.Fifth, + 6 => (int)WeekNumberWithinMonth.Sixth, + _ => throw new InvalidOperationException( "Invalid Week Number" ), + }; + + /// + /// Converts a flag value to its + /// 1-based week number integer equivalent. + /// + internal static int GetWeekNum( WeekNumberWithinMonth weekNumberWithinMonth ) + => weekNumberWithinMonth switch { + WeekNumberWithinMonth.First => 1, + WeekNumberWithinMonth.Second => 2, + WeekNumberWithinMonth.Third => 3, + WeekNumberWithinMonth.Fourth => 4, + WeekNumberWithinMonth.Fifth => 5, + WeekNumberWithinMonth.Sixth => 6, + _ => throw new InvalidOperationException( "Invalid Week Number" ), + }; +} diff --git a/src/Werkr.Data/Seeding/HolidayCalendarSeeder.cs b/src/Werkr.Data/Seeding/HolidayCalendarSeeder.cs new file mode 100644 index 0000000..bb83a6e --- /dev/null +++ b/src/Werkr.Data/Seeding/HolidayCalendarSeeder.cs @@ -0,0 +1,279 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Data.Seeding; + +/// +/// Seeds system holiday calendars (US Federal Holidays, Federal Reserve Holidays) +/// on application startup. Idempotent — checks by name before inserting. +/// +public static class HolidayCalendarSeeder { + // Deterministic GUIDs for system calendar IDs (stable across environments) + private static readonly Guid s_usFederalCalendarId = + new( "A0000001-0000-0000-0000-000000000001" ); + private static readonly Guid s_fedReserveCalendarId = + new( "A0000001-0000-0000-0000-000000000002" ); + + /// + /// Seeds default system holiday calendars if they do not already exist. + /// Called during application startup. + /// + /// The application's root . + public static async Task SeedAsync( IServiceProvider services ) { + using IServiceScope scope = services.CreateScope( ); + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + ILogger logger = services.GetRequiredService( ) + .CreateLogger( "Werkr.Data.Seeding.HolidayCalendarSeeder" ); + + await SeedUsFederalHolidaysAsync( db, logger ); + await SeedFedReserveHolidaysAsync( db, logger ); + } + + private static async Task SeedUsFederalHolidaysAsync( WerkrDbContext db, ILogger logger ) { + bool exists = await db.HolidayCalendars + .AnyAsync( c => c.Name == "US Federal Holidays" ); + if (exists) { + return; + } + + HolidayCalendar calendar = new( ) { + Id = s_usFederalCalendarId, + Name = "US Federal Holidays", + Description = "Official US federal holidays observed by the federal government.", + IsSystemCalendar = true, + }; + _ = db.HolidayCalendars.Add( calendar ); + + HolidayRule[] rules = GetUsFederalRules( s_usFederalCalendarId ); + db.HolidayRules.AddRange( rules ); + + _ = await db.SaveChangesAsync( ); + LogCalendarSeeded( logger, "US Federal Holidays", rules.Length ); + } + + private static async Task SeedFedReserveHolidaysAsync( WerkrDbContext db, ILogger logger ) { + bool exists = await db.HolidayCalendars + .AnyAsync( c => c.Name == "Federal Reserve Holidays" ); + if (exists) { + return; + } + + HolidayCalendar calendar = new( ) { + Id = s_fedReserveCalendarId, + Name = "Federal Reserve Holidays", + Description = "Holidays observed by the US Federal Reserve System (excludes Columbus Day).", + IsSystemCalendar = true, + }; + _ = db.HolidayCalendars.Add( calendar ); + + // Same as US Federal minus Columbus Day (Decision H13) + HolidayRule[] rules = GetFedReserveRules( s_fedReserveCalendarId ); + db.HolidayRules.AddRange( rules ); + + _ = await db.SaveChangesAsync( ); + LogCalendarSeeded( logger, "Federal Reserve Holidays", rules.Length ); + } + + private static readonly Action s_logCalendarSeeded = + LoggerMessage.Define( + LogLevel.Information, + new EventId( 1, "CalendarSeeded" ), + "Seeded system calendar: {CalendarName} ({RuleCount} rules)" ); + + private static void LogCalendarSeeded( ILogger logger, string calendarName, int ruleCount ) { + s_logCalendarSeeded( logger, calendarName, ruleCount, null ); + } + + /// + /// Builds the 11 US Federal Holiday rules. + /// + private static HolidayRule[] GetUsFederalRules( Guid calendarId ) => [ + // New Year's Day — January 1 + new( ) { + HolidayCalendarId = calendarId, + Name = "New Year's Day", + RuleType = HolidayRuleType.FixedDate, + Month = 1, + Day = 1, + ObservanceRule = ObservanceRule.SaturdayToFriday_SundayToMonday, + }, + // MLK Jr. Day — 3rd Monday in January + new( ) { + HolidayCalendarId = calendarId, + Name = "Martin Luther King Jr. Day", + RuleType = HolidayRuleType.NthWeekdayOfMonth, + Month = 1, + DayOfWeek = DayOfWeek.Monday, + WeekNumber = 3, + ObservanceRule = ObservanceRule.None, + }, + // Presidents' Day — 3rd Monday in February + new( ) { + HolidayCalendarId = calendarId, + Name = "Presidents' Day", + RuleType = HolidayRuleType.NthWeekdayOfMonth, + Month = 2, + DayOfWeek = DayOfWeek.Monday, + WeekNumber = 3, + ObservanceRule = ObservanceRule.None, + }, + // Memorial Day — Last Monday in May + new( ) { + HolidayCalendarId = calendarId, + Name = "Memorial Day", + RuleType = HolidayRuleType.LastWeekdayOfMonth, + Month = 5, + DayOfWeek = DayOfWeek.Monday, + ObservanceRule = ObservanceRule.None, + }, + // Juneteenth — June 19 (since 2021) + new( ) { + HolidayCalendarId = calendarId, + Name = "Juneteenth National Independence Day", + RuleType = HolidayRuleType.FixedDate, + Month = 6, + Day = 19, + ObservanceRule = ObservanceRule.SaturdayToFriday_SundayToMonday, + YearStart = 2021, + }, + // Independence Day — July 4 + new( ) { + HolidayCalendarId = calendarId, + Name = "Independence Day", + RuleType = HolidayRuleType.FixedDate, + Month = 7, + Day = 4, + ObservanceRule = ObservanceRule.SaturdayToFriday_SundayToMonday, + }, + // Labor Day — 1st Monday in September + new( ) { + HolidayCalendarId = calendarId, + Name = "Labor Day", + RuleType = HolidayRuleType.NthWeekdayOfMonth, + Month = 9, + DayOfWeek = DayOfWeek.Monday, + WeekNumber = 1, + ObservanceRule = ObservanceRule.None, + }, + // Columbus Day — 2nd Monday in October + new( ) { + HolidayCalendarId = calendarId, + Name = "Columbus Day", + RuleType = HolidayRuleType.NthWeekdayOfMonth, + Month = 10, + DayOfWeek = DayOfWeek.Monday, + WeekNumber = 2, + ObservanceRule = ObservanceRule.None, + }, + // Veterans Day — November 11 + new( ) { + HolidayCalendarId = calendarId, + Name = "Veterans Day", + RuleType = HolidayRuleType.FixedDate, + Month = 11, + Day = 11, + ObservanceRule = ObservanceRule.SaturdayToFriday_SundayToMonday, + }, + // Thanksgiving — 4th Thursday in November + new( ) { + HolidayCalendarId = calendarId, + Name = "Thanksgiving Day", + RuleType = HolidayRuleType.NthWeekdayOfMonth, + Month = 11, + DayOfWeek = DayOfWeek.Thursday, + WeekNumber = 4, + ObservanceRule = ObservanceRule.None, + }, + // Christmas Day — December 25 + new( ) { + HolidayCalendarId = calendarId, + Name = "Christmas Day", + RuleType = HolidayRuleType.FixedDate, + Month = 12, + Day = 25, + ObservanceRule = ObservanceRule.SaturdayToFriday_SundayToMonday, + }, + ]; + + /// + /// Builds the 10 Federal Reserve Holiday rules (same as US Federal minus Columbus Day). + /// + private static HolidayRule[] GetFedReserveRules( Guid calendarId ) => [ + new( ) { + HolidayCalendarId = calendarId, + Name = "New Year's Day", + RuleType = HolidayRuleType.FixedDate, + Month = 1, Day = 1, + ObservanceRule = ObservanceRule.SaturdayToFriday_SundayToMonday, + }, + new( ) { + HolidayCalendarId = calendarId, + Name = "Martin Luther King Jr. Day", + RuleType = HolidayRuleType.NthWeekdayOfMonth, + Month = 1, DayOfWeek = DayOfWeek.Monday, WeekNumber = 3, + ObservanceRule = ObservanceRule.None, + }, + new( ) { + HolidayCalendarId = calendarId, + Name = "Presidents' Day", + RuleType = HolidayRuleType.NthWeekdayOfMonth, + Month = 2, DayOfWeek = DayOfWeek.Monday, WeekNumber = 3, + ObservanceRule = ObservanceRule.None, + }, + new( ) { + HolidayCalendarId = calendarId, + Name = "Memorial Day", + RuleType = HolidayRuleType.LastWeekdayOfMonth, + Month = 5, DayOfWeek = DayOfWeek.Monday, + ObservanceRule = ObservanceRule.None, + }, + new( ) { + HolidayCalendarId = calendarId, + Name = "Juneteenth National Independence Day", + RuleType = HolidayRuleType.FixedDate, + Month = 6, Day = 19, + ObservanceRule = ObservanceRule.SaturdayToFriday_SundayToMonday, + YearStart = 2021, + }, + new( ) { + HolidayCalendarId = calendarId, + Name = "Independence Day", + RuleType = HolidayRuleType.FixedDate, + Month = 7, Day = 4, + ObservanceRule = ObservanceRule.SaturdayToFriday_SundayToMonday, + }, + new( ) { + HolidayCalendarId = calendarId, + Name = "Labor Day", + RuleType = HolidayRuleType.NthWeekdayOfMonth, + Month = 9, DayOfWeek = DayOfWeek.Monday, WeekNumber = 1, + ObservanceRule = ObservanceRule.None, + }, + // Columbus Day OMITTED per Decision H13 + new( ) { + HolidayCalendarId = calendarId, + Name = "Veterans Day", + RuleType = HolidayRuleType.FixedDate, + Month = 11, Day = 11, + ObservanceRule = ObservanceRule.SaturdayToFriday_SundayToMonday, + }, + new( ) { + HolidayCalendarId = calendarId, + Name = "Thanksgiving Day", + RuleType = HolidayRuleType.NthWeekdayOfMonth, + Month = 11, DayOfWeek = DayOfWeek.Thursday, WeekNumber = 4, + ObservanceRule = ObservanceRule.None, + }, + new( ) { + HolidayCalendarId = calendarId, + Name = "Christmas Day", + RuleType = HolidayRuleType.FixedDate, + Month = 12, Day = 25, + ObservanceRule = ObservanceRule.SaturdayToFriday_SundayToMonday, + }, + ]; +} diff --git a/src/Werkr.Data/SqliteDesignTimeFactory.cs b/src/Werkr.Data/SqliteDesignTimeFactory.cs new file mode 100644 index 0000000..3bd5e13 --- /dev/null +++ b/src/Werkr.Data/SqliteDesignTimeFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Werkr.Data; + +/// +/// Design-time factory for , used by dotnet ef CLI +/// when --context SqliteWerkrDbContext is specified. +/// +public class SqliteDesignTimeFactory : IDesignTimeDbContextFactory { + /// + public SqliteWerkrDbContext CreateDbContext( string[] args ) { + DbContextOptionsBuilder optionsBuilder = new( ); + _ = optionsBuilder.UseSqlite( "Data Source=werkr_design.db" ) + .UseSnakeCaseNamingConvention( ); + return new SqliteWerkrDbContext( optionsBuilder.Options ); + } +} diff --git a/src/Werkr.Data/SqliteWerkrDbContext.cs b/src/Werkr.Data/SqliteWerkrDbContext.cs new file mode 100644 index 0000000..0d7b275 --- /dev/null +++ b/src/Werkr.Data/SqliteWerkrDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + +namespace Werkr.Data; + +/// +/// SQLite-specific used for migration generation and runtime resolution. +/// EF Core requires a distinct type per provider so each set of migrations gets its own +/// . +/// +/// Creates a new SQLite-targeted instance. +/// The SQLite-configured options. +public class SqliteWerkrDbContext( DbContextOptions options ) + : WerkrDbContext( options ) { } diff --git a/src/Werkr.Data/Werkr.Data.csproj b/src/Werkr.Data/Werkr.Data.csproj new file mode 100644 index 0000000..ddf6c1c --- /dev/null +++ b/src/Werkr.Data/Werkr.Data.csproj @@ -0,0 +1,23 @@ + + + + Werkr.Data + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + true + + + + + + + + + + + diff --git a/src/Werkr.Data/WerkrDbContext.cs b/src/Werkr.Data/WerkrDbContext.cs new file mode 100644 index 0000000..b0bb325 --- /dev/null +++ b/src/Werkr.Data/WerkrDbContext.cs @@ -0,0 +1,452 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Werkr.Common.Models; +using Werkr.Data.Calendar.Enums; +using Werkr.Data.Entities; +using Werkr.Data.Entities.Registration; +using Werkr.Data.Entities.Schedule; +using Werkr.Data.Entities.Tasks; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Data; + +/// +/// Primary DbContext for Werkr application data (non-Identity). +/// +public class WerkrDbContext : DbContext { + /// Creates a new instance configured with the specified options. + /// The strongly-typed options for this context. + public WerkrDbContext( DbContextOptions options ) : base( options ) { } + + /// Creates a new instance for use by derived provider-specific contexts. + /// The options forwarded from a derived context. + protected WerkrDbContext( DbContextOptions options ) : base( options ) { } + + /// Pending registration bundles (server-side only). + public DbSet RegistrationBundles => Set( ); + + /// Established connections between Server and Agent. + public DbSet RegisteredConnections => Set( ); + + /// Schedules. + public DbSet Schedules => Set( ); + + /// Start date/time info for schedules. + public DbSet StartDateTimeInfos => Set( ); + + /// Expiration date/time info for schedules. + public DbSet ExpirationDateTimeInfos => Set( ); + + /// Schedule repeat options. + public DbSet ScheduleRepeatOptions => Set( ); + + /// Daily recurrences. + public DbSet DailyRecurrences => Set( ); + + /// Weekly recurrences. + public DbSet WeeklyRecurrences => Set( ); + + /// Monthly recurrences. + public DbSet MonthlyRecurrences => Set( ); + + /// Tasks. + public DbSet Tasks => Set( ); + + /// Jobs. + public DbSet Jobs => Set( ); + + /// Workflows. + public DbSet Workflows => Set( ); + + /// Workflow steps. + public DbSet WorkflowSteps => Set( ); + + /// Workflow step dependencies (many-to-many join table). + public DbSet WorkflowStepDependencies => Set( ); + + /// Workflow execution runs. + public DbSet WorkflowRuns => Set( ); + + /// Holiday calendars. + public DbSet HolidayCalendars => Set( ); + + /// Holiday rules. + public DbSet HolidayRules => Set( ); + + /// Holiday dates (materialized and manual). + public DbSet HolidayDates => Set( ); + + /// Schedule-to-holiday-calendar junction. + public DbSet ScheduleHolidayCalendars => Set( ); + + /// Schedule audit log for suppressed occurrences. + public DbSet ScheduleAuditLogs => Set( ); + + /// + protected override void OnModelCreating( ModelBuilder modelBuilder ) { + base.OnModelCreating( modelBuilder ); + + // Set default schema for Postgres (SQLite doesn't support schemas) + if (Database.ProviderName != "Microsoft.EntityFrameworkCore.Sqlite") { + _ = modelBuilder.HasDefaultSchema( "werkr" ); + } + + // RegistrationBundle indexes + _ = modelBuilder.Entity( entity => { + _ = entity.HasIndex( e => e.BundleId ) + .IsUnique( ); + + // BundleId byte[] needs explicit comparer (hex value converter) + entity.Property( e => e.BundleId ).Metadata.SetValueComparer( + new ValueComparer( + ( a, b ) => (a == null && b == null) || (a != null && b != null && a.SequenceEqual( b )), + v => v == null ? 0 : v.Aggregate( 0, ( hash, b ) => HashCode.Combine( hash, b ) ), + v => v == null ? Array.Empty( ) : v.ToArray( ) ) ); + + // RegistrationBundle.AllowedPaths stored as JSON + PropertyBuilder allowedPathsProp = entity.Property( e => e.AllowedPaths ) + .HasConversion( + v => JsonSerializer.Serialize( v, (JsonSerializerOptions?)null ), + v => JsonSerializer.Deserialize( v, (JsonSerializerOptions?)null ) ?? Array.Empty( ) ); + allowedPathsProp.Metadata.SetValueComparer( + new ValueComparer( + ( a, b ) => (a == null && b == null) || (a != null && b != null && a.SequenceEqual( b )), + v => v == null ? 0 : v.Aggregate( 0, ( hash, item ) => HashCode.Combine( hash, item.GetHashCode( StringComparison.OrdinalIgnoreCase ) ) ), + v => v == null ? Array.Empty( ) : v.ToArray( ) ) ); + } ); + + // RegisteredConnection indexes + _ = modelBuilder.Entity( entity => { + _ = entity.HasIndex( e => e.ConnectionName ); + _ = entity.HasIndex( e => e.RemoteUrl ); + + // byte[] properties with hex value converter need explicit comparers + entity.Property( e => e.SharedKey ).Metadata.SetValueComparer( + new ValueComparer( + ( a, b ) => (a == null && b == null) || (a != null && b != null && a.SequenceEqual( b )), + v => v == null ? 0 : v.Aggregate( 0, ( hash, b ) => HashCode.Combine( hash, b ) ), + v => v == null ? Array.Empty( ) : v.ToArray( ) ) ); + + entity.Property( e => e.PreviousSharedKey ).Metadata.SetValueComparer( + new ValueComparer( + ( a, b ) => (a == null && b == null) || (a != null && b != null && a.SequenceEqual( b )), + v => v == null ? 0 : v.Aggregate( 0, ( hash, b ) => HashCode.Combine( hash, b ) ), + v => v == null ? null : v.ToArray( ) ) ); + } ); + + // MonthlyRecurrence.DayNumbers stored as JSON + _ = modelBuilder.Entity( entity => { + PropertyBuilder prop = entity.Property( e => e.DayNumbers ) + .HasConversion( + v => v == null ? null : JsonSerializer.Serialize( v, (JsonSerializerOptions?)null ), + v => v == null ? null : JsonSerializer.Deserialize( v, (JsonSerializerOptions?)null ) ); + prop.Metadata.SetValueComparer( + new ValueComparer( + ( a, b ) => (a == null && b == null) || (a != null && b != null && a.SequenceEqual( b )), + v => v == null ? 0 : v.Aggregate( 0, ( hash, item ) => HashCode.Combine( hash, item ) ), + v => v == null ? null : v.ToArray( ) ) ); + } ); + + // RegisteredConnection.Tags stored as JSON + _ = modelBuilder.Entity( entity => { + PropertyBuilder prop = entity.Property( e => e.Tags ) + .HasConversion( + v => JsonSerializer.Serialize( v, (JsonSerializerOptions?)null ), + v => JsonSerializer.Deserialize( v, (JsonSerializerOptions?)null ) ?? Array.Empty( ) ); + prop.Metadata.SetValueComparer( + new ValueComparer( + ( a, b ) => (a == null && b == null) || (a != null && b != null && a.SequenceEqual( b )), + v => v == null ? 0 : v.Aggregate( 0, ( hash, item ) => HashCode.Combine( hash, item.GetHashCode( StringComparison.OrdinalIgnoreCase ) ) ), + v => v == null ? Array.Empty( ) : v.ToArray( ) ) ); + + // RegisteredConnection.AllowedPaths stored as JSON + PropertyBuilder allowedPathsProp = entity.Property( e => e.AllowedPaths ) + .HasConversion( + v => JsonSerializer.Serialize( v, (JsonSerializerOptions?)null ), + v => JsonSerializer.Deserialize( v, (JsonSerializerOptions?)null ) ?? Array.Empty( ) ); + allowedPathsProp.Metadata.SetValueComparer( + new ValueComparer( + ( a, b ) => (a == null && b == null) || (a != null && b != null && a.SequenceEqual( b )), + v => v == null ? 0 : v.Aggregate( 0, ( hash, item ) => HashCode.Combine( hash, item.GetHashCode( StringComparison.OrdinalIgnoreCase ) ) ), + v => v == null ? Array.Empty( ) : v.ToArray( ) ) ); + } ); + + // WerkrTask.TargetTags stored as JSON + _ = modelBuilder.Entity( entity => { + PropertyBuilder targetTagsProp = entity.Property( e => e.TargetTags ) + .HasConversion( + v => JsonSerializer.Serialize( v, (JsonSerializerOptions?)null ), + v => JsonSerializer.Deserialize( v, (JsonSerializerOptions?)null ) ?? Array.Empty( ) ); + targetTagsProp.Metadata.SetValueComparer( + new ValueComparer( + ( a, b ) => (a == null && b == null) || (a != null && b != null && a.SequenceEqual( b )), + v => v == null ? 0 : v.Aggregate( 0, ( hash, item ) => HashCode.Combine( hash, item.GetHashCode( StringComparison.OrdinalIgnoreCase ) ) ), + v => v == null ? Array.Empty( ) : v.ToArray( ) ) ); + + // WerkrTask.Arguments stored as JSON + PropertyBuilder argsProp = entity.Property( e => e.Arguments ) + .HasConversion( + v => v == null ? null : JsonSerializer.Serialize( v, (JsonSerializerOptions?)null ), + v => v == null ? null : JsonSerializer.Deserialize( v, (JsonSerializerOptions?)null ) ); + argsProp.Metadata.SetValueComparer( + new ValueComparer( + ( a, b ) => (a == null && b == null) || (a != null && b != null && a.SequenceEqual( b )), + v => v == null ? 0 : v.Aggregate( 0, ( hash, item ) => HashCode.Combine( hash, item ) ), + v => v == null ? null : v.ToArray( ) ) ); + } ); + + // WorkflowStepDependency composite key and relationships + _ = modelBuilder.Entity( entity => { + _ = entity.HasKey( e => new { e.StepId, e.DependsOnStepId } ); + + _ = entity.HasOne( e => e.Step ) + .WithMany( s => s.Dependencies ) + .HasForeignKey( e => e.StepId ) + .OnDelete( DeleteBehavior.Cascade ); + + _ = entity.HasOne( e => e.DependsOnStep ) + .WithMany( s => s.Dependents ) + .HasForeignKey( e => e.DependsOnStepId ) + .OnDelete( DeleteBehavior.Restrict ); + } ); + + // HolidayCalendar + _ = modelBuilder.Entity( entity => { + _ = entity.HasIndex( e => e.Name ).IsUnique( ); + + _ = entity.HasMany( e => e.Rules ) + .WithOne( r => r.Calendar ) + .HasForeignKey( r => r.HolidayCalendarId ) + .OnDelete( DeleteBehavior.Cascade ); + + _ = entity.HasMany( e => e.Dates ) + .WithOne( d => d.Calendar ) + .HasForeignKey( d => d.HolidayCalendarId ) + .OnDelete( DeleteBehavior.Cascade ); + } ); + + // HolidayRule + _ = modelBuilder.Entity( entity => { + _ = entity.Property( e => e.Id ).UseIdentityAlwaysColumn( ); + + _ = entity.HasMany( e => e.GeneratedDates ) + .WithOne( d => d.GeneratedByRule ) + .HasForeignKey( d => d.HolidayRuleId ) + .OnDelete( DeleteBehavior.SetNull ); + } ); + + // HolidayDate — unique index on (CalendarId, Date): one entry per calendar per date. + _ = modelBuilder.Entity( entity => { + _ = entity.Property( e => e.Id ).UseIdentityAlwaysColumn( ); + + _ = entity.HasIndex( e => new { e.HolidayCalendarId, e.Date } ) + .IsUnique( ); + } ); + + // ScheduleHolidayCalendar — composite PK + unique index on ScheduleId (one calendar per schedule, H5) + _ = modelBuilder.Entity( entity => { + _ = entity.HasKey( e => new { e.ScheduleId, e.HolidayCalendarId } ); + _ = entity.HasIndex( e => e.ScheduleId ).IsUnique( ); + + _ = entity.HasOne( e => e.Schedule ) + .WithOne( s => s.HolidayCalendarLink ) + .HasForeignKey( e => e.ScheduleId ) + .OnDelete( DeleteBehavior.Cascade ); + + _ = entity.HasOne( e => e.Calendar ) + .WithMany( c => c.ScheduleLinks ) + .HasForeignKey( e => e.HolidayCalendarId ) + .OnDelete( DeleteBehavior.Cascade ); + } ); + + // ScheduleAuditLog + _ = modelBuilder.Entity( entity => { + _ = entity.Property( e => e.Id ).UseIdentityAlwaysColumn( ); + _ = entity.HasIndex( e => new { e.ScheduleId, e.OccurrenceUtcTime } ); + + _ = entity.HasOne( e => e.Schedule ) + .WithMany( ) + .HasForeignKey( e => e.ScheduleId ) + .OnDelete( DeleteBehavior.Cascade ); + } ); + } + + /// + protected override void ConfigureConventions( ModelConfigurationBuilder configurationBuilder ) { + base.ConfigureConventions( configurationBuilder ); + + // TimeZoneInfo ↔ string (by Id) + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // DateTime ↔ string (ISO 8601) + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // RSAParameters ↔ string (JSON) + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // RegistrationStatus ↔ string + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // ConnectionStatus ↔ string + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // byte[] ↔ hex string (for BundleId, SharedKey) + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // TaskActionType ↔ string (Decision #45) + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // ErrorCategory ↔ string (Decision #45) + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // ControlStatement ↔ string (Decision #45) + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // DependencyMode ↔ string (Decision #45) + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // WorkflowRunStatus ↔ string (Decision #45) + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // HolidayRuleType ↔ string + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // ObservanceRule ↔ string + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + + // HolidayCalendarMode ↔ string + _ = configurationBuilder.Properties( ) + .HaveConversion( ); + } + + /// + public override int SaveChanges( ) { + UpdateConcurrencyBeforeSaving( ); + return base.SaveChanges( ); + } + + /// + public override int SaveChanges( bool acceptAllChangesOnSuccess ) { + UpdateConcurrencyBeforeSaving( ); + return base.SaveChanges( acceptAllChangesOnSuccess ); + } + + /// + public override Task SaveChangesAsync( CancellationToken cancellationToken = default ) { + UpdateConcurrencyBeforeSaving( ); + return base.SaveChangesAsync( cancellationToken ); + } + + /// + public override Task SaveChangesAsync( bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default ) { + UpdateConcurrencyBeforeSaving( ); + return base.SaveChangesAsync( acceptAllChangesOnSuccess, cancellationToken ); + } + + private void UpdateConcurrencyBeforeSaving( ) { + DateTime now = DateTime.UtcNow; + + foreach (EntityEntry entry in ChangeTracker.Entries( )) { + if (entry.State == EntityState.Added) { + entry.Entity.Created = now; + entry.Entity.LastUpdated = now; + entry.Entity.Version = 1; + } else if (entry.State == EntityState.Modified) { + entry.Entity.LastUpdated = now; + entry.Entity.Version++; + } + } + } + + // -- Value Converters -- + + private sealed class TimeZoneInfoStringConverter( ) + : ValueConverter( + tz => tz.Id, + id => TimeZoneInfo.FindSystemTimeZoneById( id ) ); + + private sealed class DateTimeStringConverter( ) + : ValueConverter( + dt => dt.ToString( "o" ), + s => DateTime.Parse( s ).ToUniversalTime( ) ); + + /// JSON options that include fields — required for which uses public fields, not properties. + private static readonly JsonSerializerOptions s_rsaJsonOptions = new( ) { IncludeFields = true }; + + private sealed class RSAParametersStringConverter( ) + : ValueConverter( + rsa => JsonSerializer.Serialize( rsa, s_rsaJsonOptions ), + json => JsonSerializer.Deserialize( json, s_rsaJsonOptions ) ); + + private sealed class RegistrationStatusStringConverter( ) + : ValueConverter( + status => status.ToString( ), + str => Enum.Parse( str ) ); + + private sealed class ConnectionStatusStringConverter( ) + : ValueConverter( + status => status.ToString( ), + str => Enum.Parse( str ) ); + + private sealed class ByteArrayHexConverter( ) + : ValueConverter( + bytes => Convert.ToHexString( bytes ), + hex => Convert.FromHexString( hex ) ); + + private sealed class TaskActionTypeStringConverter( ) + : ValueConverter( + v => v.ToString( ), + v => Enum.Parse( v ) ); + + private sealed class ErrorCategoryStringConverter( ) + : ValueConverter( + v => v.ToString( ), + v => Enum.Parse( v ) ); + + private sealed class ControlStatementStringConverter( ) + : ValueConverter( + v => v.ToString( ), + v => Enum.Parse( v ) ); + + private sealed class DependencyModeStringConverter( ) + : ValueConverter( + v => v.ToString( ), + v => Enum.Parse( v ) ); + + private sealed class WorkflowRunStatusStringConverter( ) + : ValueConverter( + v => v.ToString( ), + v => Enum.Parse( v ) ); + + private sealed class HolidayRuleTypeStringConverter( ) + : ValueConverter( + v => v.ToString( ), + v => Enum.Parse( v ) ); + + private sealed class ObservanceRuleStringConverter( ) + : ValueConverter( + v => v.ToString( ), + v => Enum.Parse( v ) ); + + private sealed class HolidayCalendarModeStringConverter( ) + : ValueConverter( + v => v.ToString( ), + v => Enum.Parse( v ) ); +} diff --git a/src/Werkr.Data/WerkrDbContextExtensions.cs b/src/Werkr.Data/WerkrDbContextExtensions.cs new file mode 100644 index 0000000..559abc0 --- /dev/null +++ b/src/Werkr.Data/WerkrDbContextExtensions.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Werkr.Common; + +namespace Werkr.Data; + +/// +/// Extension methods for registering with DI. +/// +public static class WerkrDbContextExtensions { + /// + /// Registers with the specified database provider and connection string. + /// Uses provider-specific derived contexts so that EF Core migrations resolve correctly at runtime. + /// + public static IServiceCollection AddWerkrDbContext( + this IServiceCollection services, + DatabaseProvider provider, + string connectionString ) { + switch (provider) { + case DatabaseProvider.Postgres: + _ = services.AddDbContext( options => { + _ = options.UseNpgsql( connectionString, npgsql => + npgsql.MigrationsHistoryTable( "__EFMigrationsHistory", "werkr" ) ) + .UseSnakeCaseNamingConvention( ); + } ); + _ = services.AddScoped( sp => sp.GetRequiredService( ) ); + break; + + case DatabaseProvider.SQLite: + _ = services.AddDbContext( options => { + _ = options.UseSqlite( connectionString ) + .UseSnakeCaseNamingConvention( ); + } ); + _ = services.AddScoped( sp => sp.GetRequiredService( ) ); + break; + + default: + throw new ArgumentOutOfRangeException( nameof( provider ), provider, "Unsupported database provider." ); + } + + return services; + } +} diff --git a/src/Werkr.Data/packages.lock.json b/src/Werkr.Data/packages.lock.json new file mode 100644 index 0000000..5dfe2cb --- /dev/null +++ b/src/Werkr.Data/packages.lock.json @@ -0,0 +1,537 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "EFCore.NamingConventions": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "OPZ/u7fONQFmnyUIDB8SeJtKnyFkj1zJsZ0Ke2Cp17q8hYs6jGmYEFd6Ne4Hdcd6auUdFdV7di+uFo2w+L34NA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.AspNetCore.Authorization": "[10.0.3, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.3, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Microsoft.AspNetCore.Authorization": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + } + } + } +} \ No newline at end of file diff --git a/src/Werkr.Server/Components/App.razor b/src/Werkr.Server/Components/App.razor new file mode 100644 index 0000000..441bc6a --- /dev/null +++ b/src/Werkr.Server/Components/App.razor @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Werkr.Server/Components/Layout/MainLayout.razor b/src/Werkr.Server/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..a2421b7 --- /dev/null +++ b/src/Werkr.Server/Components/Layout/MainLayout.razor @@ -0,0 +1,39 @@ +@inherits LayoutComponentBase +@using System.Reflection + +
+ + +
+
+ + +
+ @context.User.Identity?.Name + + @(context.User.IsInRole( "Admin" ) ? "Admin" + : context.User.IsInRole( "Operator" ) ? "Operator" + : "Viewer") + +
+
+
+
+ +
+ @Body +
+ +
+ Werkr @(Assembly.GetEntryAssembly()?.GetCustomAttribute()?.InformationalVersion ?? "dev") +
+
+
+ +
diff --git a/src/Werkr.Server/Components/Layout/MainLayout.razor.css b/src/Werkr.Server/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..0d66b4a --- /dev/null +++ b/src/Werkr.Server/Components/Layout/MainLayout.razor.css @@ -0,0 +1,98 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-color: var(--sidebar-bg, #1e1e1e); +} + +.top-row { + background-color: var(--topbar-bg, #333337); + border-bottom: 1px solid var(--topbar-border, #5e5e5e); + color: var(--body-content-color, inherit); + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + overflow-x: hidden; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/src/Werkr.Server/Components/Layout/NavMenu.razor b/src/Werkr.Server/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..17b4ab9 --- /dev/null +++ b/src/Werkr.Server/Components/Layout/NavMenu.razor @@ -0,0 +1,175 @@ +@using System.Reflection + + + + + + diff --git a/src/Werkr.Server/Components/Layout/NavMenu.razor.css b/src/Werkr.Server/Components/Layout/NavMenu.razor.css new file mode 100644 index 0000000..7fffc7f --- /dev/null +++ b/src/Werkr.Server/Components/Layout/NavMenu.razor.css @@ -0,0 +1,248 @@ +.navbar-toggler { + appearance: none; + cursor: pointer; + width: 3.5rem; + height: 2.5rem; + color: white; + position: absolute; + top: 0.5rem; + right: 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1); +} + +.navbar-toggler:checked { + background-color: rgba(255, 255, 255, 0.5); +} + +.top-row { + min-height: 3.5rem; + background-color: rgba(0,0,0,0.4); + display: flex; + align-items: center; + justify-content: space-between; +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep a { + color: var(--sidebar-text, #d7d7d7); + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + } + +.nav-item ::deep a.active { + background-color: var(--sidebar-active-bg, rgba(255,255,255,0.37)); + color: white; +} + +.nav-item ::deep a:hover { + background-color: var(--sidebar-hover-bg, rgba(255,255,255,0.1)); + color: white; +} + +.nav-scrollable { + display: none; +} + +.navbar-toggler:checked ~ .nav-scrollable { + display: block; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .nav-scrollable { + /* Never collapse the sidebar for wide screens */ + display: block; + + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} + +/* ── Collapsible nav group (details/summary) ── */ + +.nav-group { + padding-top: 0.5rem; +} + + /* Remove the native disclosure triangle */ + .nav-group > summary { + list-style: none; + } + + .nav-group > summary::-webkit-details-marker { + display: none; + } + + .nav-group > summary::marker { + display: none; + content: ""; + } + +.nav-group-toggle { + display: flex; + align-items: center; + width: 100%; + padding: 0.5rem 0.75rem; + color: var(--sidebar-text, #d7d7d7); + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + border-radius: 4px; + user-select: none; +} + +.nav-group-toggle:hover { + background-color: var(--sidebar-hover-bg, rgba(255,255,255,0.1)); + color: white; +} + +.nav-group-toggle .bi:first-child { + display: inline-block; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.5rem; +} + +.nav-chevron { + margin-left: auto; + width: 0.75rem !important; + height: 0.75rem !important; + margin-right: 0 !important; + transition: transform 0.2s ease; + transform: rotate(-90deg); +} + +.nav-group[open] > summary .nav-chevron { + transform: rotate(0deg); +} + +.nav-group-items { + padding-left: 0.25rem; +} + +/* ── Sidebar footer (theme toggle + version) ── */ +.sidebar-footer { + padding: 0.75rem 1rem; + border-top: 1px solid rgba(255,255,255,0.1); + margin-top: auto; +} + +.theme-toggle { + font-size: 0.85rem; +} + +.theme-icon { + font-size: 1rem; + color: var(--sidebar-text, #d7d7d7); +} + +.theme-label { + font-size: 0.75rem; + color: var(--sidebar-text, #d7d7d7); +} + +.theme-toggle .form-check.form-switch { + min-height: unset; + padding-left: 2.5em; +} + +.theme-toggle .form-check-input { + cursor: pointer; +} + +.sidebar-footer .version-text { + font-size: 0.7rem; + color: var(--text-muted-color, #777); + margin-top: 0.25rem; +} + +/* ── Icon-rail (collapsed sidebar) ── */ +.sidebar.collapsed .nav-group-toggle span.nav-group-label, +.sidebar.collapsed .nav-item ::deep a span.nav-label, +.sidebar.collapsed .navbar-brand-text, +.sidebar.collapsed .nav-chevron, +.sidebar.collapsed .sidebar-footer .theme-toggle span.theme-label, +.sidebar.collapsed .sidebar-footer .version-text { + display: none; +} + +.sidebar.collapsed .nav-item ::deep a, +.sidebar.collapsed .nav-group-toggle { + justify-content: center; + padding: 0.5rem; +} + +.sidebar.collapsed .bi { + margin-right: 0; +} + +.sidebar.collapsed .nav-group-items { + padding-left: 0; +} + +.sidebar.collapsed .nav-group { + padding-left: 0; + padding-right: 0; +} + +.sidebar.collapsed .nav-item { + padding-left: 0; + padding-right: 0; +} + +.sidebar.collapsed .sidebar-footer { + text-align: center; + padding: 0.5rem; +} + +.sidebar.collapsed .sidebar-footer .theme-toggle { + justify-content: center; +} diff --git a/src/Werkr.Server/Components/Pages/Account/AccessDenied.razor b/src/Werkr.Server/Components/Pages/Account/AccessDenied.razor new file mode 100644 index 0000000..73d1510 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Account/AccessDenied.razor @@ -0,0 +1,17 @@ +@page "/account/access-denied" + +Access Denied — Werkr + +
+
+
+

+ + Access Denied +

+

You do not have permission to access this page.

+

Contact your administrator if you believe this is an error.

+ Return Home +
+
+
diff --git a/src/Werkr.Server/Components/Pages/Account/ChangePassword.razor b/src/Werkr.Server/Components/Pages/Account/ChangePassword.razor new file mode 100644 index 0000000..54be3b2 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Account/ChangePassword.razor @@ -0,0 +1,154 @@ +@page "/account/change-password" +@attribute [Authorize] +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Identity +@using Werkr.Data.Identity.Entities +@using Werkr.Server.Components.Shared +@inject UserManager UserManager +@inject SignInManager SignInManager +@inject NavigationManager Navigation + +Change Password + +
+

Change Password

+ + + @if (_isForcedChange) { + + } + + @if (!string.IsNullOrWhiteSpace( _errorMessage )) { + + } + + @if (_identityErrors.Count > 0) { + + } + + @if (_isSuccess) { + + } + + + + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + +
+ +
+
Password guidance (NIST-aligned)
+
    +
  • Use a long password (12+ characters recommended).
  • +
  • Passphrases are encouraged and easier to remember.
  • +
  • Avoid reused passwords from other websites.
  • +
  • Avoid common or guessable words (names, dates, keyboard patterns).
  • +
  • Use a password manager when possible.
  • +
+
+
+ +@code { + [CascadingParameter] + public HttpContext HttpContext { get; set; } = default!; + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "My Account", "/account/manage" ), + new( "Change Password" ) + ]; + + [SupplyParameterFromForm] + private ChangePasswordModel? _model { get; set; } + + private readonly List _identityErrors = []; + private bool _isForcedChange; + private bool _isSuccess; + private string? _errorMessage; + + protected override async Task OnInitializedAsync( ) { + _model ??= new( ); + WerkrUser? user = await UserManager.GetUserAsync( HttpContext.User ); + _isForcedChange = user?.ChangePassword == true; + } + + private async Task HandleSubmit( ) { + _isSuccess = false; + _errorMessage = null; + _identityErrors.Clear( ); + + if (_model!.NewPassword != _model.ConfirmPassword) { + _errorMessage = "The new password and confirmation password do not match."; + return; + } + + WerkrUser? user = await UserManager.GetUserAsync( HttpContext.User ); + + if (user is null) { + _errorMessage = "Unable to load your account."; + return; + } + + IdentityResult result = await UserManager.ChangePasswordAsync( + user, + _model!.CurrentPassword, + _model.NewPassword ); + + if (!result.Succeeded) { + _identityErrors.AddRange( result.Errors.Select( e => e.Description ) ); + return; + } + + user.ChangePassword = false; + IdentityResult updateResult = await UserManager.UpdateAsync( user ); + if (!updateResult.Succeeded) { + _identityErrors.AddRange( updateResult.Errors.Select( e => e.Description ) ); + return; + } + + await SignInManager.RefreshSignInAsync( user ); + + _isSuccess = true; + Navigation.NavigateTo( "/", forceLoad: true ); + } + + private sealed class ChangePasswordModel { + [Required] + public string CurrentPassword { get; set; } = string.Empty; + + [Required] + [MinLength( 12 )] + public string NewPassword { get; set; } = string.Empty; + + [Required] + public string ConfirmPassword { get; set; } = string.Empty; + } +} diff --git a/src/Werkr.Server/Components/Pages/Account/Login.razor b/src/Werkr.Server/Components/Pages/Account/Login.razor new file mode 100644 index 0000000..d30416d --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Account/Login.razor @@ -0,0 +1,155 @@ +@page "/account/login" +@using Microsoft.AspNetCore.Identity +@using Microsoft.AspNetCore.Components +@using Werkr.Data.Identity.Entities +@using Werkr.Server.Helpers +@inject SignInManager SignInManager +@inject UserManager UserManager +@inject NavigationManager Navigation +@inject ILogger Logger + +Login — Werkr + +
+
+
+
+
+

Werkr Login

+ + @if (!string.IsNullOrEmpty( _errorMessage )) { + + } + + @if (_isLockedOut) { + + } + + + + +
+ + + +
+ +
+ + + +
+ +
+ + +
+ + +
+
+
+
+
+
+ +@code { + [CascadingParameter] + public HttpContext HttpContext { get; set; } = default!; + + [SupplyParameterFromQuery( Name = "returnUrl" )] + public string? ReturnUrl { get; set; } + + [SupplyParameterFromForm] + private LoginModel? _model { get; set; } + + private string? _errorMessage; + private bool _isLockedOut; + private DateTimeOffset? _lockoutEnd; + + protected override void OnInitialized( ) { + _model ??= new( ); + } + + private async Task HandleLogin( ) { + _errorMessage = null; + _isLockedOut = false; + _lockoutEnd = null; + + string remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString( ) ?? "unknown"; + + try { + WerkrUser? user = await UserManager.FindByEmailAsync( _model!.Email ); + if (user is not null && !user.Enabled) { + _errorMessage = "Your account has been disabled. Contact an administrator."; + Logger.LogWarning( "Login blocked for disabled account {Email} from {RemoteIp}", + _model.Email, remoteIp ); + return; + } + + Microsoft.AspNetCore.Identity.SignInResult result = + await SignInManager.PasswordSignInAsync( + _model!.Email, _model.Password, _model.RememberMe, lockoutOnFailure: true ); + + if (result.Succeeded) { + if (user is not null) { + user.LastLoginUtc = DateTime.UtcNow; + _ = await UserManager.UpdateAsync( user ); + } + + string safeUrl = UrlValidator.IsLocalUrl( ReturnUrl ) ? ReturnUrl! : "/"; + + Logger.LogInformation( "Login succeeded for {Email} from {RemoteIp}", + _model!.Email, remoteIp ); + + Navigation.NavigateTo( safeUrl, forceLoad: true ); + } else if (result.IsLockedOut) { + _isLockedOut = true; + _lockoutEnd = user is not null + ? await UserManager.GetLockoutEndDateAsync( user ) + : null; + _errorMessage = "Account locked out. Please try again later."; + + Logger.LogWarning( "Account locked out: {Email} from {RemoteIp}", + _model!.Email, remoteIp ); + } else if (result.RequiresTwoFactor) { + string safeUrl = UrlValidator.IsLocalUrl( ReturnUrl ) ? ReturnUrl! : "/"; + string encodedReturnUrl = Uri.EscapeDataString( safeUrl ); + Navigation.NavigateTo( $"/account/mfa-verify?returnUrl={encodedReturnUrl}", forceLoad: true ); + } else { + _errorMessage = "Invalid email or password."; + + Logger.LogWarning( "Login failed for {Email} from {RemoteIp}: {Reason}", + _model!.Email, remoteIp, "Invalid credentials" ); + } + } catch (NavigationException) { + throw; + } catch (Exception ex) { + _errorMessage = "An error occurred during sign-in. Please try again."; + + Logger.LogWarning( ex, "Login failed for {Email} from {RemoteIp}: {Reason}", + _model!.Email, remoteIp, "Unhandled exception" ); + } + } + + private sealed class LoginModel { + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.EmailAddress] + public string Email { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required] + public string Password { get; set; } = string.Empty; + + public bool RememberMe { get; set; } + } +} diff --git a/src/Werkr.Server/Components/Pages/Account/Logout.razor b/src/Werkr.Server/Components/Pages/Account/Logout.razor new file mode 100644 index 0000000..ccca527 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Account/Logout.razor @@ -0,0 +1,14 @@ +@page "/account/logout" +@using Microsoft.AspNetCore.Identity +@using Werkr.Data.Identity.Entities +@inject SignInManager SignInManager +@inject NavigationManager Navigation + +Logout — Werkr + +@code { + protected override async Task OnInitializedAsync( ) { + await SignInManager.SignOutAsync( ); + Navigation.NavigateTo( "/account/login", forceLoad: true ); + } +} diff --git a/src/Werkr.Server/Components/Pages/Account/Manage/Index.razor b/src/Werkr.Server/Components/Pages/Account/Manage/Index.razor new file mode 100644 index 0000000..27578b7 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Account/Manage/Index.razor @@ -0,0 +1,121 @@ +@page "/account/manage" +@rendermode InteractiveServer +@attribute [Authorize] +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Identity +@using Werkr.Data.Identity.Entities +@inject UserManager UserManager +@inject AuthenticationStateProvider AuthState + +My Account + +
+

My Account

+ + + @if (!string.IsNullOrWhiteSpace( _errorMessage )) { +
@_errorMessage
+ } + + @if (_model is not null) { +
+
+
Profile
+ + + +
+ + + +
+ +
+ + +
+ + +
+
+
+ +
+
+
Password
+ Change Password +
+
+ +
+
+
MFA
+

Status: @(_mfaEnabled ? "Enabled" : "Not enabled")

+

Recovery codes remaining: @_recoveryCodeCount

+ Manage MFA +
+
+ +
+
+
Session
+

Current session management details will be expanded in a future phase.

+
+
+ } +
+ +@code { + [SupplyParameterFromForm] + private AccountProfileModel? _model { get; set; } + + private WerkrUser? _currentUser; + private bool _mfaEnabled; + private int _recoveryCodeCount; + private string? _errorMessage; + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "My Account" ) + ]; + + protected override async Task OnInitializedAsync( ) { + AuthenticationState authState = await AuthState.GetAuthenticationStateAsync( ); + _currentUser = await UserManager.GetUserAsync( authState.User ); + + if (_currentUser is null) { + _errorMessage = "Unable to load account details."; + return; + } + + _model ??= new AccountProfileModel { + Name = _currentUser.Name, + Email = _currentUser.Email ?? string.Empty + }; + + _mfaEnabled = _currentUser.TwoFactorEnabled; + _recoveryCodeCount = await UserManager.CountRecoveryCodesAsync( _currentUser ); + } + + private async Task SaveProfileAsync( ) { + if (_currentUser is null || _model is null) { + return; + } + + _currentUser.Name = _model.Name.Trim( ); + IdentityResult result = await UserManager.UpdateAsync( _currentUser ); + _errorMessage = result.Succeeded + ? null + : string.Join( " ", result.Errors.Select( e => e.Description ) ); + } + + private sealed class AccountProfileModel { + [Required] + [StringLength( 100 )] + public string Name { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; + } +} diff --git a/src/Werkr.Server/Components/Pages/Account/Manage/Mfa.razor b/src/Werkr.Server/Components/Pages/Account/Manage/Mfa.razor new file mode 100644 index 0000000..8e0a1b4 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Account/Manage/Mfa.razor @@ -0,0 +1,287 @@ +@page "/account/manage/mfa" +@rendermode InteractiveServer +@attribute [Authorize] +@using System.ComponentModel.DataAnnotations +@using System.Globalization +@using System.Text +@using System.Text.Encodings.Web +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Identity +@using QRCoder +@using Werkr.Data.Identity.Entities +@using Werkr.Server.Components.Shared +@inject UserManager UserManager +@inject AuthenticationStateProvider AuthState +@inject NavigationManager Navigation +@inject ILogger Logger + +MFA Management + +
+

Multi-Factor Authentication

+ + + @if (_isMfaRequired) { +
Admin accounts require MFA. Please enroll now.
+ } + + @if (!string.IsNullOrWhiteSpace( _statusMessage )) { +
@_statusMessage
+ } + + @if (!string.IsNullOrWhiteSpace( _errorMessage )) { +
@_errorMessage
+ } + + @if (_isEnabled) { +
+
+
MFA is enabled
+

Recovery codes remaining: @_recoveryCodeCount

+ +
+ + +
+ + +
+ + +
+ +
+
+ } else { +
+
+
Set up authenticator app
+

Scan this QR code using your authenticator app.

+ + @if (!string.IsNullOrWhiteSpace( _qrBase64 )) { + Authenticator QR code + } + +

Manual entry key

+ @_formattedSharedKey + + + +
+ + + +
+ +
+
+
+ } + + @if (_recoveryCodes.Count > 0) { +
Save these codes. They will not be shown again.
+
@string.Join(Environment.NewLine, _recoveryCodes)
+ } + + +
+ +@code { + [SupplyParameterFromQuery( Name = "required" )] + public bool RequiredByPolicy { get; set; } + + private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6&period=30"; + + [SupplyParameterFromForm] + private VerifyCodeModel? _verifyModel { get; set; } + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "My Account", "/account/manage" ), + new( "MFA" ) + ]; + private readonly List _recoveryCodes = []; + + private WerkrUser? _currentUser; + private bool _isEnabled; + private bool _isMfaRequired; + private int _recoveryCodeCount; + private string? _sharedKey; + private string? _formattedSharedKey; + private string? _qrBase64; + private string? _statusMessage; + private string? _errorMessage; + private string _resetPassword = string.Empty; + private string _regenPassword = string.Empty; + + protected override async Task OnInitializedAsync( ) { + _verifyModel ??= new( ); + _isMfaRequired = RequiredByPolicy; + await LoadAsync( ); + } + + private async Task LoadAsync( ) { + _errorMessage = null; + _statusMessage = null; + + AuthenticationState authState = await AuthState.GetAuthenticationStateAsync( ); + _currentUser = await UserManager.GetUserAsync( authState.User ); + + if (_currentUser is null) { + Navigation.NavigateTo( "/account/login", forceLoad: true ); + return; + } + + _isEnabled = await UserManager.GetTwoFactorEnabledAsync( _currentUser ); + _recoveryCodeCount = await UserManager.CountRecoveryCodesAsync( _currentUser ); + + if (_isEnabled) { + return; + } + + string? authenticatorKey = await UserManager.GetAuthenticatorKeyAsync( _currentUser ); + if (string.IsNullOrWhiteSpace( authenticatorKey )) { + await UserManager.ResetAuthenticatorKeyAsync( _currentUser ); + authenticatorKey = await UserManager.GetAuthenticatorKeyAsync( _currentUser ); + } + + _sharedKey = authenticatorKey; + _formattedSharedKey = FormatKey( authenticatorKey ?? string.Empty ); + + string issuer = UrlEncoder.Default.Encode( "Werkr" ); + string username = UrlEncoder.Default.Encode( _currentUser.Email ?? _currentUser.UserName ?? "user" ); + string otpAuthUri = string.Format( + CultureInfo.InvariantCulture, + AuthenticatorUriFormat, + issuer, + username, + authenticatorKey ); + + QRCodeGenerator qrGenerator = new( ); + QRCodeData qrCodeData = qrGenerator.CreateQrCode( otpAuthUri, QRCodeGenerator.ECCLevel.Q ); + PngByteQRCode qrCode = new( qrCodeData ); + byte[] qrBytes = qrCode.GetGraphic( 5 ); + _qrBase64 = $"data:image/png;base64,{Convert.ToBase64String( qrBytes )}"; + } + + private async Task VerifyEnrollmentAsync( ) { + _errorMessage = null; + + if (_currentUser is null) { + await LoadAsync( ); + return; + } + + string verificationCode = (_verifyModel!.Code ?? string.Empty) + .Replace( " ", string.Empty, StringComparison.Ordinal ) + .Replace( "-", string.Empty, StringComparison.Ordinal ); + + Logger.LogInformation( "MFA enrollment verification attempted for user {UserId}", _currentUser.Id ); + + bool isValid = await UserManager.VerifyTwoFactorTokenAsync( + _currentUser, + UserManager.Options.Tokens.AuthenticatorTokenProvider, + verificationCode ); + + if (!isValid) { + Logger.LogWarning( "MFA verification failed for user {UserId}", _currentUser.Id ); + _errorMessage = "Verification code is invalid."; + return; + } + + Logger.LogInformation( "MFA verification succeeded for user {UserId}, enabling 2FA", _currentUser.Id ); + + IdentityResult result = await UserManager.SetTwoFactorEnabledAsync( _currentUser, true ); + if (!result.Succeeded) { + Logger.LogError( "Failed to enable 2FA for user {UserId}: {Errors}", + _currentUser.Id, string.Join( "; ", result.Errors.Select( e => e.Description ) ) ); + _errorMessage = string.Join( " ", result.Errors.Select( e => e.Description ) ); + return; + } + + IEnumerable newCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync( _currentUser, 10 ) + ?? Array.Empty( ); + _recoveryCodes.Clear( ); + _recoveryCodes.AddRange( newCodes ); + + Logger.LogInformation( "MFA enabled and recovery codes generated for user {UserId}", _currentUser.Id ); + _statusMessage = "MFA has been enabled."; + await LoadAsync( ); + } + + private async Task ResetMfaAsync( ) { + _errorMessage = null; + + if (_currentUser is null) { + await LoadAsync( ); + return; + } + + if (string.IsNullOrWhiteSpace( _resetPassword ) + || !await UserManager.CheckPasswordAsync( _currentUser, _resetPassword )) { + _errorMessage = "Current password is required to reset MFA."; + return; + } + + IdentityResult disableResult = await UserManager.SetTwoFactorEnabledAsync( _currentUser, false ); + if (!disableResult.Succeeded) { + _errorMessage = string.Join( " ", disableResult.Errors.Select( e => e.Description ) ); + return; + } + + await UserManager.ResetAuthenticatorKeyAsync( _currentUser ); + _resetPassword = string.Empty; + _statusMessage = "MFA has been reset. Enroll again with your authenticator app."; + await LoadAsync( ); + } + + private async Task RegenerateRecoveryCodesAsync( ) { + _errorMessage = null; + + if (_currentUser is null) { + await LoadAsync( ); + return; + } + + if (string.IsNullOrWhiteSpace( _regenPassword ) + || !await UserManager.CheckPasswordAsync( _currentUser, _regenPassword )) { + _errorMessage = "Current password is required to regenerate recovery codes."; + return; + } + + IEnumerable newCodes = await UserManager.GenerateNewTwoFactorRecoveryCodesAsync( _currentUser, 10 ) + ?? Array.Empty( ); + _recoveryCodes.Clear( ); + _recoveryCodes.AddRange( newCodes ); + _regenPassword = string.Empty; + _statusMessage = "Recovery codes regenerated."; + await LoadAsync( ); + } + + private static string FormatKey( string unformattedKey ) { + if (string.IsNullOrWhiteSpace( unformattedKey )) { + return string.Empty; + } + + StringBuilder output = new( ); + for (int index = 0; index < unformattedKey.Length; index += 4) { + int length = Math.Min( 4, unformattedKey.Length - index ); + _ = output.Append( unformattedKey.AsSpan( index, length ) ); + if (index + length < unformattedKey.Length) { + _ = output.Append( ' ' ); + } + } + + return output.ToString( ).ToLowerInvariant( ); + } + + private sealed class VerifyCodeModel { + [Required] + [StringLength( 7, MinimumLength = 6 )] + public string Code { get; set; } = string.Empty; + } +} diff --git a/src/Werkr.Server/Components/Pages/Account/MfaRecovery.razor b/src/Werkr.Server/Components/Pages/Account/MfaRecovery.razor new file mode 100644 index 0000000..6cf3d10 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Account/MfaRecovery.razor @@ -0,0 +1,75 @@ +@page "/account/mfa-recovery" +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Identity +@using Werkr.Data.Identity.Entities +@using Werkr.Server.Helpers +@inject SignInManager SignInManager +@inject NavigationManager Navigation + +MFA Recovery + +
+

Recovery Code Verification

+ + @if (!string.IsNullOrWhiteSpace( _errorMessage )) { +
@_errorMessage
+ } + + + + +
+ + + +
+ + + Use authenticator code +
+
+ +@code { + [SupplyParameterFromQuery( Name = "returnUrl" )] + public string? ReturnUrl { get; set; } + + [SupplyParameterFromForm] + private MfaRecoveryModel? _model { get; set; } + + private string? _errorMessage; + + protected override async Task OnInitializedAsync( ) { + _model ??= new( ); + WerkrUser? user = await SignInManager.GetTwoFactorAuthenticationUserAsync( ); + if (user is null) { + Navigation.NavigateTo( "/account/login", forceLoad: true ); + } + } + + private async Task HandleVerify( ) { + _errorMessage = null; + + string recoveryCode = (_model!.RecoveryCode ?? string.Empty) + .Trim( ).Replace( " ", string.Empty ); + + SignInResult result = await SignInManager.TwoFactorRecoveryCodeSignInAsync( recoveryCode ); + + if (result.Succeeded) { + string safeUrl = UrlValidator.IsLocalUrl( ReturnUrl ) ? ReturnUrl! : "/"; + Navigation.NavigateTo( safeUrl, forceLoad: true ); + return; + } + + _errorMessage = result.IsLockedOut + ? "Your account is locked due to too many failed attempts." + : "Invalid recovery code."; + } + + private sealed class MfaRecoveryModel { + [Required] + public string RecoveryCode { get; set; } = string.Empty; + } +} diff --git a/src/Werkr.Server/Components/Pages/Account/MfaVerify.razor b/src/Werkr.Server/Components/Pages/Account/MfaVerify.razor new file mode 100644 index 0000000..ea97d09 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Account/MfaVerify.razor @@ -0,0 +1,77 @@ +@page "/account/mfa-verify" +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Identity +@using Werkr.Data.Identity.Entities +@using Werkr.Server.Helpers +@inject SignInManager SignInManager +@inject NavigationManager Navigation + +MFA Verification + +
+

Multi-Factor Verification

+ + @if (!string.IsNullOrWhiteSpace( _errorMessage )) { +
@_errorMessage
+ } + + + + +
+ + + +
+ + + Use a recovery code +
+
+ +@code { + [SupplyParameterFromQuery( Name = "returnUrl" )] + public string? ReturnUrl { get; set; } + + [SupplyParameterFromForm] + private MfaVerifyModel? _model { get; set; } + + private string? _errorMessage; + + protected override async Task OnInitializedAsync( ) { + _model ??= new( ); + WerkrUser? user = await SignInManager.GetTwoFactorAuthenticationUserAsync( ); + if (user is null) { + Navigation.NavigateTo( "/account/login", forceLoad: true ); + } + } + + private async Task HandleVerify( ) { + _errorMessage = null; + + string code = (_model!.AuthenticatorCode ?? string.Empty) + .Trim( ).Replace( " ", string.Empty ).Replace( "-", string.Empty ); + + SignInResult result = await SignInManager.TwoFactorAuthenticatorSignInAsync( + code, isPersistent: false, rememberClient: false ); + + if (result.Succeeded) { + string safeUrl = UrlValidator.IsLocalUrl( ReturnUrl ) ? ReturnUrl! : "/"; + Navigation.NavigateTo( safeUrl, forceLoad: true ); + return; + } + + _errorMessage = result.IsLockedOut + ? "Your account is locked due to too many failed attempts." + : "Invalid authenticator code."; + } + + private sealed class MfaVerifyModel { + [Required] + [StringLength( 7, MinimumLength = 6 )] + public string AuthenticatorCode { get; set; } = string.Empty; + } +} diff --git a/src/Werkr.Server/Components/Pages/Admin/CreateUser.razor b/src/Werkr.Server/Components/Pages/Admin/CreateUser.razor new file mode 100644 index 0000000..753d921 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Admin/CreateUser.razor @@ -0,0 +1,175 @@ +@page "/admin/users/create" +@rendermode InteractiveServer +@attribute [Authorize(Roles = "Admin")] +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Identity +@using Werkr.Data.Identity.Entities +@using Werkr.Server.Components.Shared +@inject UserManager UserManager +@inject NavigationManager Navigation + +Create User + +
+

Create User

+ + + @if (!string.IsNullOrWhiteSpace( _errorMessage )) { +
@_errorMessage
+ } + + + + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + + Cancel +
+
+ +@code { + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Users", "/admin/users" ), + new( "Create" ) + ]; + + [SupplyParameterFromForm] + private CreateUserModel? _model { get; set; } + + private string? _errorMessage; + + protected override void OnInitialized( ) { + _model ??= new( ); + } + + private async Task HandleCreateAsync( ) { + _errorMessage = null; + + if (_model!.Password != _model.ConfirmPassword) { + _errorMessage = "Passwords do not match."; + return; + } + + List roles = []; + if (_model.IsAdmin) { + roles.Add( "Admin" ); + } + if (_model.IsOperator) { + roles.Add( "Operator" ); + } + if (_model.IsViewer) { + roles.Add( "Viewer" ); + } + + if (roles.Count == 0) { + _errorMessage = "At least one role is required."; + return; + } + + bool requireMfa = _model.RequireMfa || _model.IsAdmin; + + WerkrUser user = new( ) { + UserName = _model.Email.Trim( ), + Email = _model.Email.Trim( ), + Name = _model.Name.Trim( ), + Enabled = true, + ChangePassword = _model.ForcePasswordChange, + Requires2FA = requireMfa, + EmailConfirmed = true + }; + + IdentityResult createResult = await UserManager.CreateAsync( user, _model.Password ); + if (!createResult.Succeeded) { + _errorMessage = string.Join( " ", createResult.Errors.Select( error => error.Description ) ); + return; + } + + IdentityResult roleResult = await UserManager.AddToRolesAsync( user, roles ); + if (!roleResult.Succeeded) { + _errorMessage = string.Join( " ", roleResult.Errors.Select( error => error.Description ) ); + return; + } + + Navigation.NavigateTo( "/admin/users", forceLoad: true ); + } + + private sealed class CreateUserModel { + [Required] + [StringLength( 100 )] + public string Name { get; set; } = string.Empty; + + [Required] + [EmailAddress] + public string Email { get; set; } = string.Empty; + + [Required] + [MinLength( 12 )] + public string Password { get; set; } = string.Empty; + + [Required] + public string ConfirmPassword { get; set; } = string.Empty; + + public bool IsAdmin { get; set; } + + public bool IsOperator { get; set; } + + public bool IsViewer { get; set; } = true; + + public bool ForcePasswordChange { get; set; } = true; + + public bool RequireMfa { get; set; } + } +} diff --git a/src/Werkr.Server/Components/Pages/Admin/EditUser.razor b/src/Werkr.Server/Components/Pages/Admin/EditUser.razor new file mode 100644 index 0000000..227bffc --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Admin/EditUser.razor @@ -0,0 +1,293 @@ +@page "/admin/users/{UserId}" +@rendermode InteractiveServer +@attribute [Authorize(Roles = "Admin")] +@using System.ComponentModel.DataAnnotations +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Identity +@using Werkr.Data.Identity.Entities +@inject UserManager UserManager +@inject AuthenticationStateProvider AuthState +@inject NavigationManager Navigation + +Edit User + +
+

Edit User

+ + + @if (!string.IsNullOrWhiteSpace( _errorMessage )) { +
@_errorMessage
+ } + + @if (!string.IsNullOrWhiteSpace( _statusMessage )) { +
@_statusMessage
+ } + + @if (_model is not null) { + + + +
+ + +
+ +
+ + +
+ + +
+ +
+ +
Roles
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
Account Status
+ + + + +
+ +
Danger Zone
+ + } +
+ +@code { + [Parameter] + public string UserId { get; set; } = string.Empty; + + [SupplyParameterFromForm] + private EditUserModel? _model { get; set; } + + private WerkrUser? _user; + private string? _errorMessage; + private string? _statusMessage; + private List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Users", "/admin/users" ), + new( "Edit" ) + ]; + + protected override async Task OnParametersSetAsync( ) { + await LoadAsync( ); + } + + private async Task LoadAsync( ) { + _errorMessage = null; + _statusMessage = null; + + _user = await UserManager.FindByIdAsync( UserId ); + if (_user is null) { + _errorMessage = "User was not found."; + return; + } + + IList roles = await UserManager.GetRolesAsync( _user ); + + _model ??= new EditUserModel { + Name = _user.Name, + Email = _user.Email ?? string.Empty, + Enabled = _user.Enabled, + IsAdmin = roles.Contains( "Admin", StringComparer.OrdinalIgnoreCase ), + IsOperator = roles.Contains( "Operator", StringComparer.OrdinalIgnoreCase ), + IsViewer = roles.Contains( "Viewer", StringComparer.OrdinalIgnoreCase ) + }; + + _breadcrumbs = [ + new( "Task List", "/" ), + new( "Users", "/admin/users" ), + new( _user.Name ) + ]; + } + + private async Task SaveProfileAsync( ) { + if (_user is null || _model is null) { + return; + } + + _user.Name = _model.Name.Trim( ); + _user.Email = _model.Email.Trim( ); + _user.UserName = _model.Email.Trim( ); + + IdentityResult result = await UserManager.UpdateAsync( _user ); + if (!result.Succeeded) { + _errorMessage = string.Join( " ", result.Errors.Select( error => error.Description ) ); + return; + } + + _statusMessage = "Profile updated."; + await LoadAsync( ); + } + + private async Task SaveRolesAsync( ) { + if (_user is null || _model is null) { + return; + } + + List targetRoles = []; + if (_model.IsAdmin) { + targetRoles.Add( "Admin" ); + } + if (_model.IsOperator) { + targetRoles.Add( "Operator" ); + } + if (_model.IsViewer) { + targetRoles.Add( "Viewer" ); + } + + if (targetRoles.Count == 0) { + _errorMessage = "At least one role is required."; + return; + } + + IList currentRoles = await UserManager.GetRolesAsync( _user ); + bool removingAdmin = currentRoles.Contains( "Admin", StringComparer.OrdinalIgnoreCase ) + && !targetRoles.Contains( "Admin", StringComparer.OrdinalIgnoreCase ); + + if (removingAdmin && await IsLastAdminAsync( _user )) { + _errorMessage = "Cannot remove the last Admin user."; + return; + } + + IdentityResult removeResult = await UserManager.RemoveFromRolesAsync( _user, currentRoles ); + if (!removeResult.Succeeded) { + _errorMessage = string.Join( " ", removeResult.Errors.Select( error => error.Description ) ); + return; + } + + IdentityResult addResult = await UserManager.AddToRolesAsync( _user, targetRoles ); + if (!addResult.Succeeded) { + _errorMessage = string.Join( " ", addResult.Errors.Select( error => error.Description ) ); + return; + } + + if (_model.IsAdmin && !_user.Requires2FA) { + _user.Requires2FA = true; + _ = await UserManager.UpdateAsync( _user ); + } + + _statusMessage = "Roles updated."; + await LoadAsync( ); + } + + private async Task ToggleEnabledAsync( ) { + if (_user is null || _model is null) { + return; + } + + if (_user.Enabled && await UserManager.IsInRoleAsync( _user, "Admin" ) && await IsLastAdminAsync( _user )) { + _errorMessage = "Cannot disable the last Admin user."; + return; + } + + _user.Enabled = !_user.Enabled; + IdentityResult result = await UserManager.UpdateAsync( _user ); + if (!result.Succeeded) { + _errorMessage = string.Join( " ", result.Errors.Select( error => error.Description ) ); + return; + } + + _statusMessage = _user.Enabled ? "Account enabled." : "Account disabled."; + await LoadAsync( ); + } + + private async Task ForcePasswordResetAsync( ) { + if (_user is null) { + return; + } + + _user.ChangePassword = true; + IdentityResult result = await UserManager.UpdateAsync( _user ); + if (!result.Succeeded) { + _errorMessage = string.Join( " ", result.Errors.Select( error => error.Description ) ); + return; + } + + _statusMessage = "Password reset requirement applied."; + await LoadAsync( ); + } + + private async Task ForceMfaReenrollmentAsync( ) { + if (_user is null) { + return; + } + + IdentityResult disableResult = await UserManager.SetTwoFactorEnabledAsync( _user, false ); + if (!disableResult.Succeeded) { + _errorMessage = string.Join( " ", disableResult.Errors.Select( error => error.Description ) ); + return; + } + + await UserManager.ResetAuthenticatorKeyAsync( _user ); + _statusMessage = "MFA re-enrollment has been enforced."; + await LoadAsync( ); + } + + private async Task DeleteUserAsync( ) { + if (_user is null) { + return; + } + + AuthenticationState authState = await AuthState.GetAuthenticationStateAsync( ); + string? currentUserId = UserManager.GetUserId( authState.User ); + if (string.Equals( currentUserId, _user.Id, StringComparison.OrdinalIgnoreCase )) { + _errorMessage = "You cannot delete your own account."; + return; + } + + if (await UserManager.IsInRoleAsync( _user, "Admin" ) && await IsLastAdminAsync( _user )) { + _errorMessage = "Cannot delete the last Admin user."; + return; + } + + IdentityResult result = await UserManager.DeleteAsync( _user ); + if (!result.Succeeded) { + _errorMessage = string.Join( " ", result.Errors.Select( error => error.Description ) ); + return; + } + + Navigation.NavigateTo( "/admin/users", forceLoad: true ); + } + + private async Task IsLastAdminAsync( WerkrUser user ) { + IList admins = await UserManager.GetUsersInRoleAsync( "Admin" ); + return admins.Count == 1 && admins[0].Id == user.Id; + } + + private sealed class EditUserModel { + [Required] + [StringLength( 100 )] + public string Name { get; set; } = string.Empty; + + [Required] + [EmailAddress] + public string Email { get; set; } = string.Empty; + + public bool Enabled { get; set; } + + public bool IsAdmin { get; set; } + + public bool IsOperator { get; set; } + + public bool IsViewer { get; set; } + } +} diff --git a/src/Werkr.Server/Components/Pages/Admin/Users.razor b/src/Werkr.Server/Components/Pages/Admin/Users.razor new file mode 100644 index 0000000..6471e2c --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Admin/Users.razor @@ -0,0 +1,188 @@ +@page "/admin/users" +@rendermode InteractiveServer +@attribute [Authorize(Roles = "Admin")] +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Identity +@using Microsoft.EntityFrameworkCore +@using Werkr.Data.Identity.Entities +@inject UserManager UserManager + +User Management + +
+

Users

+ +

Manage user accounts, role assignments, and security flags.

+ +
+ Create User + +
+ + @if (!string.IsNullOrWhiteSpace( _errorMessage )) { +
@_errorMessage
+ } + + @if (!string.IsNullOrWhiteSpace( _statusMessage )) { +
@_statusMessage
+ } + +
+ + + + + + + + + + + + + + + @foreach (UserRow row in _users) { + + + + + + + + + + + } + +
NameEmailRolesEnabledMFAPassword ResetLast LoginActions
@row.Name@row.Email@string.Join( ", ", row.Roles ) + + @(row.Enabled ? "Enabled" : "Disabled") + + + + @(row.TwoFactorEnabled ? "Enabled" : "Off") + + + + @(row.ChangePassword ? "Pending" : "No") + + @(row.LastLoginUtc?.ToLocalTime( ).ToString( "g" ) ?? "Never") + Edit + + +
+
+
+ +@code { + private readonly List _users = []; + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Users" ) + ]; + private string? _errorMessage; + private string? _statusMessage; + + protected override async Task OnInitializedAsync( ) { + await LoadUsersAsync( ); + } + + private async Task LoadUsersAsync( ) { + _errorMessage = null; + _statusMessage = null; + _users.Clear( ); + + List users = await UserManager.Users.OrderBy( user => user.Name ).ToListAsync( ); + foreach (WerkrUser user in users) { + IList roles = await UserManager.GetRolesAsync( user ); + _users.Add( new UserRow { + Id = user.Id, + Name = user.Name, + Email = user.Email ?? string.Empty, + Roles = [.. roles], + Enabled = user.Enabled, + TwoFactorEnabled = user.TwoFactorEnabled, + ChangePassword = user.ChangePassword, + LastLoginUtc = user.LastLoginUtc + } ); + } + } + + private async Task ToggleEnabledAsync( string userId ) { + _errorMessage = null; + _statusMessage = null; + + WerkrUser? user = await UserManager.FindByIdAsync( userId ); + if (user is null) { + _errorMessage = "User was not found."; + return; + } + + if (user.Enabled) { + int adminCount = await CountAdminsAsync( ); + bool isAdmin = await UserManager.IsInRoleAsync( user, "Admin" ); + if (isAdmin && adminCount <= 1) { + _errorMessage = "Cannot disable the last Admin user."; + return; + } + } + + user.Enabled = !user.Enabled; + IdentityResult result = await UserManager.UpdateAsync( user ); + if (!result.Succeeded) { + _errorMessage = string.Join( " ", result.Errors.Select( error => error.Description ) ); + return; + } + + _statusMessage = $"User {(user.Enabled ? "enabled" : "disabled")}."; + await LoadUsersAsync( ); + } + + private async Task ForcePasswordResetAsync( string userId ) { + _errorMessage = null; + _statusMessage = null; + + WerkrUser? user = await UserManager.FindByIdAsync( userId ); + if (user is null) { + _errorMessage = "User was not found."; + return; + } + + user.ChangePassword = true; + IdentityResult result = await UserManager.UpdateAsync( user ); + if (!result.Succeeded) { + _errorMessage = string.Join( " ", result.Errors.Select( error => error.Description ) ); + return; + } + + _statusMessage = "Password reset requirement has been set."; + await LoadUsersAsync( ); + } + + private async Task CountAdminsAsync( ) { + IList admins = await UserManager.GetUsersInRoleAsync( "Admin" ); + return admins.Count; + } + + private sealed class UserRow { + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; + + public List Roles { get; set; } = []; + + public bool Enabled { get; set; } + + public bool TwoFactorEnabled { get; set; } + + public bool ChangePassword { get; set; } + + public DateTime? LastLoginUtc { get; set; } + } +} diff --git a/src/Werkr.Server/Components/Pages/AgentDetail.razor b/src/Werkr.Server/Components/Pages/AgentDetail.razor new file mode 100644 index 0000000..cdd4f93 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/AgentDetail.razor @@ -0,0 +1,264 @@ +@page "/agents/{AgentId:guid}" +@rendermode InteractiveServer +@attribute [Authorize(Roles = "Admin")] +@using Werkr.Common.Models +@using Werkr.Server.Helpers +@using Microsoft.AspNetCore.Components.Web +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation + +Agent Detail + +
+

Agent Detail

+ + + @if (!string.IsNullOrWhiteSpace( _errorMessage )) { +
@_errorMessage
+ } + + @if (_agent is not null) { +
+
+
@_agent.ConnectionName
+ @_agent.Status +
+
Connection ID
+
@_agent.Id
+ +
Remote URL
+
@_agent.RemoteUrl
+ +
Registered At
+
@_agent.RegisteredAt.ToLocalTime( ).ToString( "g" )
+ +
Last Seen
+
@(_agent.LastSeen?.ToLocalTime( ).ToString( "g" ) ?? "Never")
+
+
+
+ +
+
+
Tags
+ +
+
+ +
+
+
Security
+

RSA fingerprint

+ @DisplayFingerprint( _agent.RsaKeyFingerprint ) + @if (_agent.RsaKeyFingerprint.Length > 24) { + + } +
+
+ +
+
+
Operator Availability
+

PowerShell: @FormatAvailability( _agent.PowerShellAvailable )

+

System Shell: @FormatAvailability( _agent.SystemShellAvailable )

+ Open Console +
+
+ +
+
+
Actions
+ + @if (_isEditingName) { +
+ + +
+ + + } else { + + } + +
+ + @if (_isEditingUrl) { +
+ + +
Changing the URL will drop the cached gRPC channel and reconnect.
+
+ + + } else { + + } + + @if (_agent.Status != "Revoked") { + + } + + Back +
+
+ } +
+ +@code { + [Parameter] + public Guid AgentId { get; set; } + + private AgentDetailDto? _agent; + private string? _errorMessage; + private bool _isEditingName; + private string _editedName = string.Empty; + private bool _isEditingUrl; + private string _editedUrl = string.Empty; + private bool _showFullFingerprint; + private string[] _tags = []; + private List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Agents", "/agents" ), + new( "Detail" ) + ]; + + protected override async Task OnParametersSetAsync( ) { + await LoadAsync( ); + } + + private async Task LoadAsync( ) { + _errorMessage = null; + + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _agent = await client.GetFromJsonAsync( $"/api/agents/{AgentId}" ); + _editedName = _agent?.ConnectionName ?? string.Empty; + _editedUrl = _agent?.RemoteUrl ?? string.Empty; + if (_agent is not null) { + _breadcrumbs = [ + new( "Task List", "/" ), + new( "Agents", "/agents" ), + new( _agent.ConnectionName ) + ]; + _tags = await client.GetFromJsonAsync( $"/api/agents/{AgentId}/tags" ) ?? []; + } + } catch (Exception ex) { + _errorMessage = $"Failed to load agent details: {ex.Message}"; + } + } + + private void BeginEdit( ) { + _isEditingName = true; + } + + private void CancelEdit( ) { + _isEditingName = false; + _editedName = _agent?.ConnectionName ?? string.Empty; + } + + private void BeginUrlEdit( ) { + _isEditingUrl = true; + } + + private void CancelUrlEdit( ) { + _isEditingUrl = false; + _editedUrl = _agent?.RemoteUrl ?? string.Empty; + } + + private async Task SaveNameAsync( ) { + if (_agent is null) { + return; + } + + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + UpdateAgentRequest request = new( _editedName, null ); + HttpResponseMessage response = await client.PutAsJsonAsync( $"/api/agents/{_agent.Id}", request ); + if (!response.IsSuccessStatusCode) { + _errorMessage = $"Failed to update name: {(int) response.StatusCode}"; + return; + } + + _isEditingName = false; + await LoadAsync( ); + } catch (Exception ex) { + _errorMessage = $"Failed to update name: {ex.Message}"; + } + } + + private async Task SaveUrlAsync( ) { + if (_agent is null) { + return; + } + + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + UpdateAgentRequest request = new( null, _editedUrl ); + HttpResponseMessage response = await client.PutAsJsonAsync( $"/api/agents/{_agent.Id}", request ); + if (!response.IsSuccessStatusCode) { + string body = await response.Content.ReadAsStringAsync( ); + _errorMessage = $"Failed to update URL: {(int) response.StatusCode} — {body}"; + return; + } + + _isEditingUrl = false; + await LoadAsync( ); + } catch (Exception ex) { + _errorMessage = $"Failed to update URL: {ex.Message}"; + } + } + + private async Task RevokeAsync( ) { + if (_agent is null) { + return; + } + + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PostAsync( $"/api/agents/{_agent.Id}/revoke", null ); + if (!response.IsSuccessStatusCode) { + _errorMessage = $"Failed to revoke agent: {(int) response.StatusCode}"; + return; + } + + await LoadAsync( ); + } catch (Exception ex) { + _errorMessage = $"Failed to revoke agent: {ex.Message}"; + } + } + + private void ToggleFingerprint( ) { + _showFullFingerprint = !_showFullFingerprint; + } + + private async Task OnTagsChanged( string[] tags ) { + await SaveTagsAsync( tags ); + } + + private async Task SaveTagsAsync( string[] tags ) { + if (_agent is null) return; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PutAsJsonAsync( + $"/api/agents/{_agent.Id}/tags", new { Tags = tags } ); + if (response.IsSuccessStatusCode) { + _tags = await response.Content.ReadFromJsonAsync( ) ?? []; + } else { + _errorMessage = $"Failed to update tags: {(int) response.StatusCode}"; + } + } catch (Exception ex) { + _errorMessage = $"Failed to update tags: {ex.Message}"; + } + } + + private string DisplayFingerprint( string fingerprint ) { + if (_showFullFingerprint || fingerprint.Length <= 24) { + return fingerprint; + } + + return $"{fingerprint[..24]}..."; + } + + private static string FormatAvailability( bool? value ) => AgentDisplayHelper.FormatAvailability( value ); + private static string GetStatusBadgeClass( string status ) => AgentDisplayHelper.GetStatusBadgeClass( status ); +} diff --git a/src/Werkr.Server/Components/Pages/Agents.razor b/src/Werkr.Server/Components/Pages/Agents.razor new file mode 100644 index 0000000..b06feae --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Agents.razor @@ -0,0 +1,124 @@ +@page "/agents" +@rendermode InteractiveServer +@using Microsoft.AspNetCore.Authorization +@using Werkr.Common.Models +@using Werkr.Server.Helpers +@attribute [Authorize( Roles = "Admin" )] +@inject IHttpClientFactory HttpClientFactory + +Werkr - Agents + +

Registered Agents

+ + +

Manage your Werkr agent connections.

+ +
+ Register New Agent + +
+ +@if ( !string.IsNullOrEmpty( _errorMessage ) ) { +
@_errorMessage
+} + +@if ( _agents is not null && _agents.Count > 0 ) { +
+ + + + + + + + + + + + + @foreach ( AgentListDto agent in _agents ) { + + + + + + + + + } + +
NameURLStatusLast SeenRegistered AtActions
@agent.ConnectionName@agent.RemoteUrl + @agent.Status + @( agent.LastSeen?.ToString( "g" ) ?? "—" )@agent.RegisteredAt.ToString( "g" ) + View Details + @if ( agent.Status == "Connected" ) { + + + + + + } + @if ( agent.Status != "Revoked" ) { + + } +
+
+} else if ( !_isLoading ) { +
+ No agents registered yet. Click "Register New Agent" to get started. +
+} + +@code { + private List? _agents; + private string? _errorMessage; + private bool _isLoading; + private readonly List _breadcrumbs = [ + new( "Home", "/" ), + new( "Agents" ) + ]; + + /// + protected override async Task OnInitializedAsync( ) { + await LoadAgents( ); + } + + private async Task LoadAgents( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _agents = await client.GetFromJsonAsync>( "/api/agents" ); + } catch ( Exception ex ) { + _errorMessage = $"Failed to load agents: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private async Task RevokeAgent( Guid agentId ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PostAsync( $"/api/agents/{agentId}/revoke", null ); + if ( response.IsSuccessStatusCode ) { + await LoadAgents( ); + } else { + _errorMessage = $"Failed to revoke agent: {(int) response.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to revoke agent: {ex.Message}"; + } + } + + private static string GetStatusBadgeClass( string status ) => AgentDisplayHelper.GetStatusBadgeClass( status ); + +} diff --git a/src/Werkr.Server/Components/Pages/Agents/Console.razor b/src/Werkr.Server/Components/Pages/Agents/Console.razor new file mode 100644 index 0000000..49a3c45 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Agents/Console.razor @@ -0,0 +1,295 @@ +@page "/agents/{AgentId:guid}/console" +@using System.Net.Http.Json +@using System.Text.Json +@using Werkr.Common.Models +@using Werkr.Common.Rendering +@using Werkr.Server.Components.Shared +@attribute [Authorize( Roles = "Admin,Operator" )] +@rendermode InteractiveServer +@inject IHttpClientFactory HttpClientFactory +@inject ILogger Logger +@implements IDisposable + +Console — Werkr + + + + + +
+ @* Header row *@ +
+
+

+ Operator Console +

+ Agent: @AgentId +
+
+ + @* Operator Type Selector + Status *@ +
+
+ + + + + +
+ + @if (_isExecuting) { + + + Executing... + + } else { + Ready + } +
+ + @* Output Area — fills remaining height *@ +
+ @if (_outputLines.Count == 0) { + Output will appear here... + } else { + @foreach (OutputLine line in _outputLines) { +
+ [@line.Timestamp] + @((MarkupString)AnsiHtmlConverter.Convert( line.Message )) +
+ } + } +
+ + @* Command Input *@ +
+ + + @if (_isExecuting) { + + } else { + + } + + +
+
+ +@code { + [Parameter] public Guid AgentId { get; set; } + + private readonly List _outputLines = new( ); + private string _command = string.Empty; + private string _selectedOperator = "PowerShell"; + private bool _isExecuting; + private CancellationTokenSource? _cts; + private ElementReference _outputDiv; + + // StatusBanner state + private bool _hasError; + private string _errorLevel = "danger"; + private string? _errorTitle; + private string? _errorMessage; + + private List _breadcrumbs = [ + new( "Agents", "/agents" ), + new( "Console" ) + ]; + + private static readonly JsonSerializerOptions s_jsonOptions = new( ) { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + private async Task ExecuteCommand( ) { + if (string.IsNullOrWhiteSpace( _command )) { + return; + } + + // Clear any previous error banner + DismissError( ); + + if (!Enum.TryParse( _selectedOperator, ignoreCase: true, out OperatorType parsedType )) { + _outputLines.Add( new OutputLine( "Error", + $"Unknown operator type: {_selectedOperator}", + DateTime.UtcNow.ToString( "O" ) ) ); + return; + } + + string commandText = _command; + _command = string.Empty; + _isExecuting = true; + + _outputLines.Add( new OutputLine( "Debug", + $"> {commandText}", + DateTime.UtcNow.ToString( "O" ) ) ); + _outputLines.Add( new OutputLine( "Debug", + $"--- Execution started ({_selectedOperator}) ---", + DateTime.UtcNow.ToString( "O" ) ) ); + + _cts = new CancellationTokenSource( ); + + try { + using HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + ExecuteCommandRequest request = new( parsedType.ToString( ), commandText ); + + using HttpRequestMessage httpRequest = new( HttpMethod.Post, + $"/api/agents/{AgentId}/shell/stream" ) { + Content = JsonContent.Create( request ) + }; + + using HttpResponseMessage response = await client.SendAsync( + httpRequest, HttpCompletionOption.ResponseHeadersRead, _cts.Token ); + + if (response.IsSuccessStatusCode) { + await using System.IO.Stream stream = await response.Content.ReadAsStreamAsync( _cts.Token ); + using System.IO.StreamReader reader = new( stream ); + + string? line; + string currentEvent = "message"; + + while ((line = await reader.ReadLineAsync( _cts.Token )) is not null) { + if (_cts.Token.IsCancellationRequested) break; + + if (line.StartsWith( "event: ", StringComparison.Ordinal )) { + currentEvent = line[7..]; + continue; + } + + if (line.StartsWith( "data: ", StringComparison.Ordinal )) { + string json = line[6..]; + + if (currentEvent == "done") { + break; + } + + if (currentEvent == "error") { + try { + using JsonDocument doc = JsonDocument.Parse( json ); + string msg = doc.RootElement.TryGetProperty( "message", out JsonElement msgEl ) + ? msgEl.GetString( ) ?? "Unknown error" + : json; + ShowError( "danger", "Command Failed", msg ); + _outputLines.Add( new OutputLine( "Error", msg, DateTime.UtcNow.ToString( "O" ) ) ); + } catch { + ShowError( "danger", "Command Failed", json ); + } + break; + } + + // Default "message" event — parse as OperatorOutputLine + try { + OperatorOutputLine? outputLine = JsonSerializer.Deserialize( json, s_jsonOptions ); + if (outputLine is not null) { + _outputLines.Add( new OutputLine( outputLine.LogLevel, outputLine.Message, outputLine.Timestamp ) ); + await InvokeAsync( StateHasChanged ); + } + } catch (JsonException ex) { + Logger.LogWarning( ex, "Failed to parse SSE data: {Json}", json ); + } + + currentEvent = "message"; // reset for next event + continue; + } + + // empty line = end of event (already handled above) + } + } else { + string body = await response.Content.ReadAsStringAsync( _cts.Token ); + string msg = string.IsNullOrWhiteSpace( body ) + ? $"API returned {(int)response.StatusCode} {response.ReasonPhrase}" + : body; + Logger.LogError( "Execute command failed: {StatusCode} — {Body}", + (int)response.StatusCode, body ); + ShowError( "danger", "Command Failed", msg ); + _outputLines.Add( new OutputLine( "Error", msg, DateTime.UtcNow.ToString( "O" ) ) ); + } + + _outputLines.Add( new OutputLine( "Debug", + "--- Execution completed ---", + DateTime.UtcNow.ToString( "O" ) ) ); + } catch (OperationCanceledException) { + _outputLines.Add( new OutputLine( "Warning", + "Execution cancelled by user.", + DateTime.UtcNow.ToString( "O" ) ) ); + } catch (HttpRequestException ex) { + Logger.LogError( ex, "HTTP request failed for Agent {AgentId}.", AgentId ); + ShowError( "danger", "Request Failed", + "Failed to reach the API. Check that the API service is running." ); + _outputLines.Add( new OutputLine( "Error", + "Failed to reach the API.", + DateTime.UtcNow.ToString( "O" ) ) ); + } catch (Exception ex) { + Logger.LogError( ex, "Unexpected error during command execution on Agent {AgentId}.", AgentId ); + ShowError( "danger", "Command Failed", "An unexpected error occurred while executing the command." ); + _outputLines.Add( new OutputLine( "Error", + "An unexpected error occurred.", + DateTime.UtcNow.ToString( "O" ) ) ); + } finally { + _isExecuting = false; + _cts?.Dispose( ); + _cts = null; + } + } + + private void ShowError( string level, string title, string message ) { + _errorLevel = level; + _errorTitle = title; + _errorMessage = message; + _hasError = true; + } + + private void DismissError( ) { + _hasError = false; + _errorTitle = null; + _errorMessage = null; + } + + private void CancelExecution( ) { + _cts?.Cancel( ); + } + + private async Task HandleKeyDown( KeyboardEventArgs e ) { + if (e.Key == "Enter" && !_isExecuting && !string.IsNullOrWhiteSpace( _command )) { + await ExecuteCommand( ); + } + } + + private void ClearOutput( ) { + _outputLines.Clear( ); + } + + private static string GetLogLevelClass( string logLevel ) => + logLevel switch { + "Trace" or "Debug" => "text-secondary", + "Information" => "text-light", + "Warning" => "text-warning", + "Error" or "Critical" => "text-danger", + _ => "text-light" + }; + + public void Dispose( ) { + _cts?.Cancel( ); + _cts?.Dispose( ); + } + + private sealed record OutputLine( string LogLevel, string Message, string Timestamp ); +} diff --git a/src/Werkr.Server/Components/Pages/Agents/Index.razor b/src/Werkr.Server/Components/Pages/Agents/Index.razor new file mode 100644 index 0000000..7ab60cb --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Agents/Index.razor @@ -0,0 +1,122 @@ +@page "/agents" +@rendermode InteractiveServer +@using Microsoft.AspNetCore.Authorization +@using Werkr.Common.Models +@using Werkr.Server.Helpers +@attribute [Authorize( Roles = "Admin" )] +@inject IHttpClientFactory HttpClientFactory + +Werkr - Agents + +

Registered Agents

+ + +

Manage your Werkr agent connections.

+ +
+ Register New Agent + +
+ +@if ( !string.IsNullOrEmpty( _errorMessage ) ) { +
@_errorMessage
+} + +@if ( _agents is not null && _agents.Count > 0 ) { +
+ + + + + + + + + + + + + @foreach ( AgentListDto agent in _agents ) { + + + + + + + + + } + +
NameURLStatusLast SeenRegistered AtActions
@agent.ConnectionName@agent.RemoteUrl + @agent.Status + @( agent.LastSeen?.ToString( "g" ) ?? "—" )@agent.RegisteredAt.ToString( "g" ) + View Details + + + + + + @if ( agent.Status != "Revoked" ) { + + } +
+
+} else if ( !_isLoading ) { +
+ No agents registered yet. Click "Register New Agent" to get started. +
+} + +@code { + private List? _agents; + private string? _errorMessage; + private bool _isLoading; + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Agents" ) + ]; + + /// + protected override async Task OnInitializedAsync( ) { + await LoadAgents( ); + } + + private async Task LoadAgents( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _agents = await client.GetFromJsonAsync>( "/api/agents" ); + } catch ( Exception ex ) { + _errorMessage = $"Failed to load agents: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private async Task RevokeAgent( Guid agentId ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PostAsync( $"/api/agents/{agentId}/revoke", null ); + if ( response.IsSuccessStatusCode ) { + await LoadAgents( ); + } else { + _errorMessage = $"Failed to revoke agent: {(int) response.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to revoke agent: {ex.Message}"; + } + } + + private static string GetStatusBadgeClass( string status ) => AgentDisplayHelper.GetStatusBadgeClass( status ); + +} diff --git a/src/Werkr.Server/Components/Pages/ApiKeys.razor b/src/Werkr.Server/Components/Pages/ApiKeys.razor new file mode 100644 index 0000000..0a8ff7d --- /dev/null +++ b/src/Werkr.Server/Components/Pages/ApiKeys.razor @@ -0,0 +1,215 @@ +@page "/admin/apikeys" +@rendermode InteractiveServer +@using Microsoft.AspNetCore.Authorization +@using Werkr.Common.Models +@attribute [Authorize( Roles = "Admin" )] +@inject IHttpClientFactory HttpClientFactory + +Werkr - API Keys + +
+

API Keys

+ + +

Manage API keys used for programmatic access to the Werkr API.

+ +
+ + +
+ + @if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { +
+ @_errorMessage + +
+ } + + @if ( _showCreate ) { +
+
+
Create API Key
+
+ + +
+
+ + +
Leave blank for a non-expiring key.
+
+ + +
+
+ } + + @if ( _createdKey is not null ) { +
+
API Key Created
+

Copy this key now — it will not be shown again.

+ @_createdKey.RawKey +
+
Name
+
@_createdKey.Name
+
Prefix
+
@_createdKey.KeyPrefix
+
Role
+
@_createdKey.Role
+
Expires
+
@(_createdKey.ExpiresUtc?.ToString( "yyyy-MM-dd HH:mm UTC" ) ?? "Never")
+
+ +
+ } + + @if ( _keys.Count == 0 && !_isLoading ) { +
No API keys found.
+ } else { +
+ + + + + + + + + + + + + + + @foreach ( ApiKeyDto key in _keys ) { + + + + + + + + + + + } + +
NamePrefixRoleCreatedExpiresLast UsedStatus
@key.Name@key.KeyPrefix@key.Role@key.CreatedUtc.ToString( "yyyy-MM-dd HH:mm" )@(key.ExpiresUtc?.ToString( "yyyy-MM-dd HH:mm" ) ?? "Never")@(key.LastUsedUtc?.ToString( "yyyy-MM-dd HH:mm" ) ?? "—") + @if ( key.IsRevoked ) { + Revoked + } else if ( key.ExpiresUtc.HasValue && key.ExpiresUtc.Value < DateTime.UtcNow ) { + Expired + } else { + Active + } + + @if ( !key.IsRevoked ) { + + } +
+
+ } +
+ +@code { + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "API Keys" ) + ]; + + private List _keys = []; + private string? _errorMessage; + private bool _isLoading; + private bool _showCreate; + private bool _isCreating; + private bool _isRevoking; + private string _newKeyName = string.Empty; + private DateTime? _newKeyExpiry; + private ApiKeyCreateResponse? _createdKey; + + protected override async Task OnInitializedAsync( ) { + await LoadKeysAsync( ); + } + + private async Task LoadKeysAsync( ) { + _isLoading = true; + _errorMessage = null; + + try { + using HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + List? result = await client.GetFromJsonAsync>( "/api/keys" ); + _keys = result ?? []; + } catch (Exception ex) { + _errorMessage = $"Failed to load API keys: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private async Task CreateKeyAsync( ) { + if (string.IsNullOrWhiteSpace( _newKeyName )) { + _errorMessage = "Key name is required."; + return; + } + + _isCreating = true; + _errorMessage = null; + + try { + using HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + ApiKeyCreateRequest request = new( _newKeyName.Trim( ), _newKeyExpiry ); + HttpResponseMessage response = await client.PostAsJsonAsync( "/api/keys", request ); + + if (response.IsSuccessStatusCode) { + _createdKey = await response.Content.ReadFromJsonAsync( ); + _showCreate = false; + _newKeyName = string.Empty; + _newKeyExpiry = null; + await LoadKeysAsync( ); + } else { + _errorMessage = $"Failed to create key: {response.StatusCode}"; + } + } catch (Exception ex) { + _errorMessage = $"Failed to create key: {ex.Message}"; + } finally { + _isCreating = false; + } + } + + private async Task RevokeKeyAsync( Guid id ) { + _isRevoking = true; + _errorMessage = null; + + try { + using HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.DeleteAsync( $"/api/keys/{id}" ); + + if (response.IsSuccessStatusCode) { + await LoadKeysAsync( ); + } else { + _errorMessage = $"Failed to revoke key: {response.StatusCode}"; + } + } catch (Exception ex) { + _errorMessage = $"Failed to revoke key: {ex.Message}"; + } finally { + _isRevoking = false; + } + } +} diff --git a/src/Werkr.Server/Components/Pages/Dashboard/Calendar.razor b/src/Werkr.Server/Components/Pages/Dashboard/Calendar.razor new file mode 100644 index 0000000..32d1eb9 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Dashboard/Calendar.razor @@ -0,0 +1,348 @@ +@page "/calendar" +@using Werkr.Server.Services +@rendermode InteractiveServer +@attribute [Authorize] +@implements IAsyncDisposable +@inject IHttpClientFactory HttpClientFactory +@inject ServerConfigCache ConfigCache + +Werkr - Calendar + +

Calendar

+ + +

Visual overview of scheduled occurrences.

+ +
+
+ + + +
+ + @if ( _viewMode == "timeline" ) { + +
@_viewDate.ToString( "dddd, MMMM d, yyyy" )
+ + + } else if ( _viewMode == "weekly" ) { + +
Week of @_weekStart.ToString( "MMM d" ) – @_weekStart.AddDays( 6 ).ToString( "MMM d, yyyy" )
+ + } else { + +
@_viewDate.ToString( "MMMM yyyy" )
+ + } + + +
+ +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _viewMode == "timeline" ) { + @* ── Hourly timeline: time (Y) × tasks/schedules (X) ── *@ +
+ + + + + @foreach ( ScheduleDto s in _schedules ) { + + } + + + + @foreach ( TimeOnly slot in _timeSlots ) { + + + @foreach ( ScheduleDto s in _schedules ) { + TimeOnly slotEnd = slot.AddMinutes( _intervalMinutes ); + List hits = GetOccurrencesInSlot( s.Id, slot, slotEnd ); + + } + + } + +
Time@s.Name
@slot.ToString( "HH:mm" ) + @if ( hits.Count > 0 ) { +
+ @hits.Count +
+ } +
+
+} else if ( _viewMode == "weekly" ) { + @* ── 7-column weekly timeline ── *@ +
+ + + + + @for (int d = 0; d < 7; d++) { + DateTime day = _weekStart.AddDays( d ); + + } + + + + @foreach ( TimeOnly slot in _timeSlots ) { + + + @for (int d = 0; d < 7; d++) { + DateTime day = _weekStart.AddDays( d ); + TimeOnly slotEnd = slot.AddMinutes( _intervalMinutes ); + List<(string Name, DateTime Occ)> hits = GetWeeklySlotHits( day, slot, slotEnd ); + + } + + } + +
Time + @day.ToString( "ddd M/d" ) +
@slot.ToString( "HH:mm" ) + @foreach (var hit in hits) { +
+ @hit.Name +
+ } +
+
+} else { + @* ── Monthly grid (original) ── *@ +
+ @foreach ( string day in s_dayHeaders ) { +
@day
+ } + + @foreach ( CalendarCell cell in _cells ) { +
+
@cell.Date.Day
+ @foreach ( CalendarEvent evt in cell.Events ) { +
+ @evt.Time.ToString( "HH:mm" ) @evt.ScheduleName +
+ } +
+ } +
+} + +@code { + private string _viewMode = "timeline"; + private DateTime _viewDate = DateTime.Today; + private DateTime _weekStart = DateTime.Today.AddDays( -(int) DateTime.Today.DayOfWeek ); + private int _intervalMinutes = 60; + private List _schedules = []; + private List _cells = []; + private List _timeSlots = []; + private Dictionary> _occurrencesBySchedule = []; + private string? _errorMessage; + private bool _isLoading; + private bool _disposed; + private PeriodicTimer? _refreshTimer; + private CancellationTokenSource? _cts; + + private static readonly string[] s_dayHeaders = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Calendar" ) + ]; + + protected override async Task OnInitializedAsync( ) { + BuildTimeSlots( ); + await LoadAsync( ); + + _cts = new CancellationTokenSource( ); + _refreshTimer = new PeriodicTimer( TimeSpan.FromSeconds( ConfigCache.PollingIntervalSeconds ) ); + _ = AutoRefreshAsync( _cts.Token ); + } + + private async Task AutoRefreshAsync( CancellationToken ct ) { + if ( _refreshTimer is null ) return; + try { + while ( await _refreshTimer.WaitForNextTickAsync( ct ) ) { + if ( _disposed ) return; + await LoadAsync( ); + await InvokeAsync( StateHasChanged ); + } + } catch ( OperationCanceledException ) { } + catch ( ObjectDisposedException ) { } + } + + private void SetViewMode( string mode ) { + _viewMode = mode; + _ = LoadAsync( ); + } + + private void BuildTimeSlots( ) { + _timeSlots = []; + for ( int m = 0; m < 24 * 60; m += _intervalMinutes ) { + _timeSlots.Add( new TimeOnly( m / 60, m % 60 ) ); + } + } + + private void PreviousDay( ) { + _viewDate = _viewDate.AddDays( -1 ); + _ = LoadAsync( ); + } + + private void NextDay( ) { + _viewDate = _viewDate.AddDays( 1 ); + _ = LoadAsync( ); + } + + private void PreviousMonth( ) { + _viewDate = _viewDate.AddMonths( -1 ); + _ = LoadAsync( ); + } + + private void NextMonth( ) { + _viewDate = _viewDate.AddMonths( 1 ); + _ = LoadAsync( ); + } + + private void PreviousWeek( ) { + _weekStart = _weekStart.AddDays( -7 ); + _ = LoadAsync( ); + } + + private void NextWeek( ) { + _weekStart = _weekStart.AddDays( 7 ); + _ = LoadAsync( ); + } + + private async Task LoadAsync( ) { + _isLoading = true; + _errorMessage = null; + BuildTimeSlots( ); + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _schedules = await client.GetFromJsonAsync>( "/api/schedules" ) ?? []; + + DateTime windowStart; + DateTime windowEnd; + if ( _viewMode == "timeline" ) { + windowStart = _viewDate.Date; + windowEnd = _viewDate.Date.AddDays( 1 ).AddSeconds( -1 ); + } else if ( _viewMode == "weekly" ) { + windowStart = _weekStart.Date; + windowEnd = _weekStart.Date.AddDays( 7 ).AddSeconds( -1 ); + } else { + windowStart = new DateTime( _viewDate.Year, _viewDate.Month, 1 ); + windowEnd = new DateTime( _viewDate.Year, _viewDate.Month, + DateTime.DaysInMonth( _viewDate.Year, _viewDate.Month ), 23, 59, 59 ); + } + + Dictionary> newOccurrences = []; + Dictionary> eventsByDate = []; + + foreach ( ScheduleDto schedule in _schedules ) { + try { + string url = $"/api/schedules/{schedule.Id}/occurrences?windowEnd={windowEnd:O}"; + OccurrencePreviewResponse? preview = + await client.GetFromJsonAsync( url ); + if ( preview?.Occurrences is not null ) { + newOccurrences[schedule.Id] = preview.Occurrences; + + foreach ( DateTime occ in preview.Occurrences ) { + DateOnly key = DateOnly.FromDateTime( occ ); + if ( !eventsByDate.TryGetValue( key, out List? list ) ) { + list = []; + eventsByDate[key] = list; + } + list.Add( new CalendarEvent( schedule.Name, TimeOnly.FromDateTime( occ ) ) ); + } + } + } catch { + // Skip individual schedule errors + } + } + + _occurrencesBySchedule = newOccurrences; + if ( _viewMode == "monthly" ) { + BuildCells( eventsByDate ); + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to load calendar data: {ex.Message}"; + BuildCells( [] ); + } finally { + _isLoading = false; + } + } + + private List GetOccurrencesInSlot( Guid scheduleId, TimeOnly slotStart, TimeOnly slotEnd ) { + if ( !_occurrencesBySchedule.TryGetValue( scheduleId, out IReadOnlyList? occs ) ) return []; + DateOnly viewDay = DateOnly.FromDateTime( _viewDate ); + return occs + .Where( o => DateOnly.FromDateTime( o ) == viewDay + && TimeOnly.FromDateTime( o ) >= slotStart + && TimeOnly.FromDateTime( o ) < slotEnd ) + .ToList( ); + } + + private List<(string Name, DateTime Occ)> GetWeeklySlotHits( DateTime day, TimeOnly slotStart, TimeOnly slotEnd ) { + DateOnly dayKey = DateOnly.FromDateTime( day ); + List<(string Name, DateTime Occ)> results = []; + foreach (ScheduleDto schedule in _schedules) { + if (!_occurrencesBySchedule.TryGetValue( schedule.Id, out IReadOnlyList? occs )) continue; + foreach (DateTime occ in occs) { + if (DateOnly.FromDateTime( occ ) == dayKey) { + TimeOnly occTime = TimeOnly.FromDateTime( occ ); + if (occTime >= slotStart && occTime < slotEnd) { + results.Add( (schedule.Name, occ) ); + } + } + } + } + return results; + } + + private void BuildCells( Dictionary> eventsByDate ) { + _cells = []; + DateOnly firstOfMonth = new( _viewDate.Year, _viewDate.Month, 1 ); + int startOffset = (int) firstOfMonth.DayOfWeek; + DateOnly cellDate = firstOfMonth.AddDays( -startOffset ); + + int totalCells = 42; // 6 rows × 7 days + DateOnly today = DateOnly.FromDateTime( DateTime.Today ); + + for ( int i = 0; i < totalCells; i++ ) { + eventsByDate.TryGetValue( cellDate, out List? events ); + _cells.Add( new CalendarCell( + cellDate, + cellDate.Month == _viewDate.Month, + cellDate == today, + events ?? [] ) ); + cellDate = cellDate.AddDays( 1 ); + } + } + + public async ValueTask DisposeAsync( ) { + if ( _disposed ) return; + _disposed = true; + if ( _cts is not null ) { + await _cts.CancelAsync( ); + _cts.Dispose( ); + } + _refreshTimer?.Dispose( ); + } + + private sealed record CalendarCell( DateOnly Date, bool IsCurrentMonth, bool IsToday, List Events ); + private sealed record CalendarEvent( string ScheduleName, TimeOnly Time ); +} diff --git a/src/Werkr.Server/Components/Pages/Dashboard/Calendar.razor.css b/src/Werkr.Server/Components/Pages/Dashboard/Calendar.razor.css new file mode 100644 index 0000000..5a27712 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Dashboard/Calendar.razor.css @@ -0,0 +1,167 @@ +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + border: 1px solid var(--bs-border-color); + border-radius: 0.375rem; + overflow: hidden; +} + +.calendar-header { + background: var(--bs-secondary-bg); + font-weight: 600; + text-align: center; + padding: 0.5rem; + border-bottom: 1px solid var(--bs-border-color); + font-size: 0.85rem; + text-transform: uppercase; +} + +.calendar-cell { + min-height: 90px; + padding: 4px 6px; + border: 1px solid var(--bs-border-color); + overflow: hidden; + background: var(--bs-body-bg); +} + +.calendar-cell-dim { + background: var(--bs-tertiary-bg); + color: var(--bs-secondary-color); +} + +.calendar-cell-today { + background: rgba(var(--bs-primary-rgb), 0.08); + border-color: var(--bs-primary); +} + +.calendar-date { + font-weight: 600; + font-size: 0.85rem; + margin-bottom: 2px; +} + +.calendar-event { + background: var(--bs-primary); + color: #fff; + border-radius: 0.25rem; + padding: 1px 4px; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.75rem; +} + +/* ── Timeline view ── */ + +.timeline-container { + max-height: 600px; + overflow-y: auto; + border: 1px solid var(--bs-border-color); + border-radius: 0.375rem; +} + +.timeline-table { + margin-bottom: 0; +} + +.timeline-time-col { + width: 80px; + min-width: 80px; + position: sticky; + left: 0; + background: var(--bs-secondary-bg); + z-index: 1; +} + +.timeline-schedule-col { + max-width: 140px; + font-size: 0.8rem; +} + +.timeline-time-cell { + font-weight: 600; + font-size: 0.8rem; + white-space: nowrap; + background: var(--bs-body-bg); + position: sticky; + left: 0; + z-index: 1; +} + +.timeline-cell-active { + background: rgba(var(--bs-primary-rgb), 0.1); +} + +.timeline-bubble { + display: inline-block; + background: var(--bs-primary); + color: #fff; + border-radius: 50%; + width: 24px; + height: 24px; + text-align: center; + line-height: 24px; + font-size: 0.75rem; + font-weight: 600; + cursor: default; +} + +/* ── Weekly view ── */ + +.weekly-container { + max-height: 600px; + overflow-y: auto; + border: 1px solid var(--bs-border-color); + border-radius: 0.375rem; +} + +.weekly-table { + margin-bottom: 0; + table-layout: fixed; +} + +.weekly-time-col { + width: 70px; + min-width: 70px; + position: sticky; + left: 0; + background: var(--bs-secondary-bg); + z-index: 1; +} + +.weekly-day-col { + font-size: 0.8rem; + text-align: center; +} + +.weekly-today { + background: rgba(var(--bs-primary-rgb), 0.12); + font-weight: 700; +} + +.weekly-time-cell { + font-weight: 600; + font-size: 0.8rem; + white-space: nowrap; + background: var(--bs-body-bg); + position: sticky; + left: 0; + z-index: 1; +} + +.weekly-cell-active { + background: rgba(var(--bs-primary-rgb), 0.08); +} + +.weekly-event { + background: var(--bs-primary); + color: #fff; + border-radius: 0.25rem; + padding: 1px 4px; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.7rem; +} diff --git a/src/Werkr.Server/Components/Pages/Dashboard/TaskList.razor b/src/Werkr.Server/Components/Pages/Dashboard/TaskList.razor new file mode 100644 index 0000000..eb71ba2 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Dashboard/TaskList.razor @@ -0,0 +1,376 @@ +@page "/" +@page "/tasklist" +@using Werkr.Server.Services +@rendermode InteractiveServer +@attribute [Authorize] +@implements IAsyncDisposable +@inject IHttpClientFactory HttpClientFactory +@inject ServerConfigCache ConfigCache + +Werkr - Task List + +

Task List

+ + +

Quick overview of all tasks with their current status and last execution result.

+ +
+ + + +
+ +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading ) { + +} else if ( FilteredTasks.Count == 0 ) { + +} else { +
+ + + + + + + + + + + + + + + + @foreach ( TaskListItem item in FilteredTasks ) { + + + + + + + + + + + + } + +
+ Status @SortIndicator("status") + + Name @SortIndicator("name") + + Action @SortIndicator("action") + Tags + Schedule @SortIndicator("schedule") + + Next Run @SortIndicator("nextrun") + + Workflow @SortIndicator("workflow") + + Last Run @SortIndicator("lastrun") + Actions
+ @if ( item.LastJob is not null && item.LastJob.EndTime is null && !item.LastJob.Success ) { + + Running + + } else if ( item.Task.Enabled ) { + @if ( item.LastJob is null ) { + + Pending + + } else if ( item.LastJob.Success ) { + + OK + + } else { + + Failed + + } + } else { + Off + } + + @item.Task.Name + @if ( !string.IsNullOrWhiteSpace( item.Task.Description ) ) { +
+ @Truncate( item.Task.Description, 80 ) + } +
@item.Task.ActionType + @if ( item.Task.TargetTags is { Length: > 0 } ) { + @foreach ( string tag in item.Task.TargetTags ) { + @tag + } + } else { + + } + + @if ( item.Task.ScheduleId is not null ) { + + Assigned + + } else { + Manual + } + + @{ string nextRunKey = $"{item.Task.Id}"; } + @if ( _nextRuns.TryGetValue( item.Task.Id, out DateTime nextRun ) ) { + @nextRun.ToString( "g" ) + } else if ( item.Task.ScheduleId is null ) { + + } else { + + } + + @if ( item.Task.WorkflowId is not null ) { + + #@item.Task.WorkflowId + + } else { + + } + + @if ( item.LastJob is not null ) { + + @( item.LastJob.Success ? "Success" : "Failed" ) + +
+ @item.LastJob.StartTime.ToString( "g" ) + } else { + Never + } +
+ + + + + + + + +
+
+ +
+ Showing @FilteredTasks.Count of @_items.Count tasks +
+} + +@code { + private List _items = []; + private Dictionary _nextRuns = new( ); + private string? _errorMessage; + private bool _isLoading; + private bool _disposed; + private PeriodicTimer? _refreshTimer; + private CancellationTokenSource? _cts; + private string _filterText = ""; + private string _filterStatus = ""; + private string _sortColumn = "name"; + private bool _sortAscending = true; + + private readonly List _breadcrumbs = [ + new( "Task List" ) + ]; + + private List FilteredTasks { + get { + IEnumerable query = _items + .Where( i => + ( string.IsNullOrWhiteSpace( _filterText ) + || i.Task.Name.Contains( _filterText, StringComparison.OrdinalIgnoreCase ) ) + && ( _filterStatus switch { + "enabled" => i.Task.Enabled, + "disabled" => !i.Task.Enabled, + _ => true + } ) ); + + query = _sortColumn switch { + "status" => _sortAscending + ? query.OrderBy( i => i.Task.Enabled ) + : query.OrderByDescending( i => i.Task.Enabled ), + "name" => _sortAscending + ? query.OrderBy( i => i.Task.Name, StringComparer.OrdinalIgnoreCase ) + : query.OrderByDescending( i => i.Task.Name, StringComparer.OrdinalIgnoreCase ), + "action" => _sortAscending + ? query.OrderBy( i => i.Task.ActionType, StringComparer.OrdinalIgnoreCase ) + : query.OrderByDescending( i => i.Task.ActionType, StringComparer.OrdinalIgnoreCase ), + "schedule" => _sortAscending + ? query.OrderBy( i => i.Task.ScheduleId.HasValue ).ThenBy( i => i.Task.ScheduleId ) + : query.OrderByDescending( i => i.Task.ScheduleId.HasValue ).ThenByDescending( i => i.Task.ScheduleId ), + "nextrun" => _sortAscending + ? query.OrderBy( i => _nextRuns.TryGetValue( i.Task.Id, out DateTime nr ) ? nr : DateTime.MaxValue ) + : query.OrderByDescending( i => _nextRuns.TryGetValue( i.Task.Id, out DateTime nr ) ? nr : DateTime.MinValue ), + "workflow" => _sortAscending + ? query.OrderBy( i => i.Task.WorkflowId ?? long.MaxValue ) + : query.OrderByDescending( i => i.Task.WorkflowId ?? long.MinValue ), + "lastrun" => _sortAscending + ? query.OrderBy( i => i.LastJob?.StartTime ?? DateTime.MaxValue ) + : query.OrderByDescending( i => i.LastJob?.StartTime ?? DateTime.MinValue ), + _ => query.OrderBy( i => i.Task.Name, StringComparer.OrdinalIgnoreCase ) + }; + + return query.ToList( ); + } + } + + private void SetSort( string column ) { + if ( _sortColumn == column ) { + _sortAscending = !_sortAscending; + } else { + _sortColumn = column; + _sortAscending = true; + } + } + + private MarkupString SortIndicator( string column ) { + if ( _sortColumn != column ) return new MarkupString( "" ); + return new MarkupString( _sortAscending + ? "" + : "" ); + } + + protected override async Task OnInitializedAsync( ) { + await LoadAsync( ); + _cts = new CancellationTokenSource( ); + _refreshTimer = new PeriodicTimer( TimeSpan.FromSeconds( ConfigCache.PollingIntervalSeconds ) ); + _ = AutoRefreshAsync( _cts.Token ); + } + + private async Task AutoRefreshAsync( CancellationToken ct ) { + if ( _refreshTimer is null ) return; + try { + while ( await _refreshTimer.WaitForNextTickAsync( ct ) ) { + if ( _disposed ) return; + await LoadAsync( ); + await InvokeAsync( StateHasChanged ); + } + } catch ( OperationCanceledException ) { } + catch ( ObjectDisposedException ) { } + } + + private async Task LoadAsync( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + List? tasks = await client.GetFromJsonAsync>( "/api/tasks" ); + if ( tasks is null ) { + _items = []; + return; + } + + List items = []; + foreach ( TaskDto task in tasks ) { + JobListDto? lastJob = null; + try { + List? jobs = await client.GetFromJsonAsync>( + $"/api/tasks/{task.Id}/jobs?limit=1" ); + lastJob = jobs?.FirstOrDefault( ); + } catch { + // Skip job lookup errors + } + items.Add( new TaskListItem( task, lastJob ) ); + } + _items = items; + + // Fetch next occurrences for schedule-bound tasks + Dictionary nextRuns = new( ); + IEnumerable scheduleIds = tasks + .Where( t => t.ScheduleId.HasValue ) + .Select( t => t.ScheduleId!.Value ) + .Distinct( ); + + foreach ( Guid scheduleId in scheduleIds ) { + try { + OccurrencePreviewResponse? response = await client.GetFromJsonAsync( + $"/api/schedules/{scheduleId}/occurrences" ); + if ( response?.Occurrences is { Count: > 0 } ) { + DateTime nextOccurrence = response.Occurrences[0]; + foreach ( TaskDto task in tasks.Where( t => t.ScheduleId == scheduleId ) ) { + nextRuns[task.Id] = nextOccurrence; + } + } + } catch { + // Skip occurrence lookup errors + } + } + _nextRuns = nextRuns; + } catch ( Exception ex ) { + _errorMessage = $"Failed to load tasks: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private async Task RunTaskAsync( long taskId ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + await client.PostAsJsonAsync( $"/api/tasks/{taskId}/run", new TaskRunRequest( null ) ); + } catch { + _errorMessage = "Failed to run task."; + } + } + + private async Task ToggleEnabledAsync( TaskDto task ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + TaskUpdateRequest request = new( + Name: task.Name, + Description: task.Description, + ActionType: task.ActionType, + Content: task.Content, + Arguments: task.Arguments, + TargetTags: task.TargetTags, + Enabled: !task.Enabled, + TimeoutMinutes: task.TimeoutMinutes, + SuccessCriteria: task.SuccessCriteria, + ScheduleId: task.ScheduleId, + WorkflowId: task.WorkflowId ); + HttpResponseMessage response = await client.PutAsJsonAsync( $"/api/tasks/{task.Id}", request ); + if ( response.IsSuccessStatusCode ) { + await LoadAsync( ); + } else { + _errorMessage = $"Failed to toggle task: HTTP {(int) response.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to toggle task: {ex.Message}"; + } + } + + private static string GetResultBadge( bool success ) => success ? "bg-success" : "bg-danger"; + + private static string Truncate( string text, int max ) => + text.Length <= max ? text : text[..max] + "…"; + + private sealed record TaskListItem( TaskDto Task, JobListDto? LastJob ); + + public ValueTask DisposeAsync( ) { + _disposed = true; + _cts?.Cancel( ); + _cts?.Dispose( ); + _refreshTimer?.Dispose( ); + return ValueTask.CompletedTask; + } +} diff --git a/src/Werkr.Server/Components/Pages/Error.razor b/src/Werkr.Server/Components/Pages/Error.razor new file mode 100644 index 0000000..82bea3d --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Error.razor @@ -0,0 +1,38 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @requestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + public HttpContext? HttpContext { get; set; } + + private string? requestId; + private bool ShowRequestId => !string.IsNullOrEmpty(requestId); + + protected override void OnInitialized() + { + requestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; + } +} diff --git a/src/Werkr.Server/Components/Pages/HolidayCalendars/Create.razor b/src/Werkr.Server/Components/Pages/HolidayCalendars/Create.razor new file mode 100644 index 0000000..d121bd0 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/HolidayCalendars/Create.razor @@ -0,0 +1,93 @@ +@page "/holiday-calendars/create" +@using System.ComponentModel.DataAnnotations +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation +@using Werkr.Common.Models.Holidays + +Werkr - New Holiday Calendar + +

Create Holiday Calendar

+ + +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + + + + + +
+
+
Calendar Information
+ +
+ + +
+ +
+ + +
+
+
+ +
+ + Cancel +
+
+ +@code { + private CalendarFormModel _model = new( ); + private string? _errorMessage; + private bool _isSaving; + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Holiday Calendars", "/holiday-calendars" ), + new( "Create" ) + ]; + + private async Task SubmitAsync( ) { + _isSaving = true; + _errorMessage = null; + + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HolidayCalendarCreateRequest request = new( _model.Name, _model.Description ?? "" ); + HttpResponseMessage response = await client.PostAsJsonAsync( "/api/holiday-calendars", request ); + + if ( response.IsSuccessStatusCode ) { + HolidayCalendarDto? created = await response.Content.ReadFromJsonAsync( ); + if ( created is not null ) { + Navigation.NavigateTo( $"/holiday-calendars/{created.Id}" ); + return; + } + } + + _errorMessage = $"Failed to create calendar: HTTP {(int) response.StatusCode}"; + } catch ( Exception ex ) { + _errorMessage = $"Failed to create calendar: {ex.Message}"; + } finally { + _isSaving = false; + } + } + + private sealed class CalendarFormModel { + [Required( ErrorMessage = "Name is required." )] + [StringLength( 256, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 256 characters." )] + public string Name { get; set; } = ""; + + [StringLength( 1024, ErrorMessage = "Description must be 1024 characters or fewer." )] + public string? Description { get; set; } + } +} diff --git a/src/Werkr.Server/Components/Pages/HolidayCalendars/Detail.razor b/src/Werkr.Server/Components/Pages/HolidayCalendars/Detail.razor new file mode 100644 index 0000000..94baa8b --- /dev/null +++ b/src/Werkr.Server/Components/Pages/HolidayCalendars/Detail.razor @@ -0,0 +1,215 @@ +@page "/holiday-calendars/{Id:guid}/detail" +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation +@using Werkr.Common.Models.Holidays + +Werkr - Holiday Calendar Detail + +

Holiday Calendar Detail

+ + +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading ) { + +} else if ( _calendar is not null ) { + + @* ── Calendar Info Card ── *@ +
+
+
+ @_calendar.Name + @if ( _calendar.IsSystemCalendar ) { + System + } +
+

@( string.IsNullOrWhiteSpace( _calendar.Description ) ? "No description." : _calendar.Description )

+
+
+ + @* ── Rules Card ── *@ +
+
+
Holiday Rules
+ @if ( _rules is not null && _rules.Count > 0 ) { +
+ + + + + + + + + + + + + @foreach ( HolidayRuleDto rule in _rules ) { + + + + + + + + + } + +
NameTypeConfigObservanceTime WindowYear Range
@rule.Name@rule.RuleType@DescribeRuleConfig( rule )@rule.ObservanceRule@FormatWindow( rule )@FormatYearRange( rule )
+
+ } else { +

No rules defined.

+ } +
+
+ + @* ── Calendar Preview Card ── *@ +
+
+
Date Preview
+
+
+ + +
+
+ + +
+
+ +
+
+ + @if ( _previewDates is not null ) { + @if ( _previewDates.Count > 0 ) { +
+ + + + @foreach ( HolidayDateDto d in _previewDates ) { + + + + + + } + +
DateNameSource
@d.Date@d.Name@( d.IsManual ? "Manual" : "Rule" )
+
+ } else { +

No dates found for the selected range.

+ } + } +
+
+ + @* ── Actions ── *@ +
+ @if ( _calendar.IsSystemCalendar ) { + + } else { + Edit + } + Back to List +
+ +} + +@code { + [Parameter] public Guid Id { get; set; } + + private HolidayCalendarDto? _calendar; + private List? _rules; + private string? _errorMessage; + private bool _isLoading; + + // Preview + private int _previewStartYear = DateTime.Now.Year; + private int _previewEndYear = DateTime.Now.Year + 1; + private List? _previewDates; + + private List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Holiday Calendars", "/holiday-calendars" ), + new( "Detail" ) + ]; + + protected override async Task OnInitializedAsync( ) { + _isLoading = true; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _calendar = await client.GetFromJsonAsync( $"/api/holiday-calendars/{Id}" ); + _rules = await client.GetFromJsonAsync>( $"/api/holiday-calendars/{Id}/rules" ); + + if ( _calendar is not null ) { + _breadcrumbs = [ + new( "Task List", "/" ), + new( "Holiday Calendars", "/holiday-calendars" ), + new( _calendar.Name ) + ]; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to load calendar: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private async Task PreviewAsync( ) { + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HolidayPreviewResponse? preview = await client.GetFromJsonAsync( + $"/api/holiday-calendars/{Id}/preview?startYear={_previewStartYear}&endYear={_previewEndYear}" ); + _previewDates = preview?.Dates?.ToList( ); + } catch ( Exception ex ) { + _errorMessage = $"Preview failed: {ex.Message}"; + } + } + + private async Task CloneAsync( ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + CloneHolidayCalendarRequest req = new( $"{_calendar!.Name} (Copy)" ); + HttpResponseMessage res = await client.PostAsJsonAsync( $"/api/holiday-calendars/{Id}/clone", req ); + if ( res.IsSuccessStatusCode ) { + HolidayCalendarDto? cloned = await res.Content.ReadFromJsonAsync( ); + if ( cloned is not null ) { + Navigation.NavigateTo( $"/holiday-calendars/{cloned.Id}" ); + return; + } + } + _errorMessage = $"Clone failed: HTTP {(int) res.StatusCode}"; + } catch ( Exception ex ) { + _errorMessage = $"Clone failed: {ex.Message}"; + } + } + + private static string DescribeRuleConfig( HolidayRuleDto rule ) => rule.RuleType switch { + "FixedDate" => $"Month {rule.Month}, Day {rule.Day}", + "NthWeekdayOfMonth" => $"#{rule.WeekNumber} {rule.DayOfWeek} of month {rule.Month}", + "LastWeekdayOfMonth" => $"Last {rule.DayOfWeek} of month {rule.Month}", + _ => "—" + }; + + private static string FormatWindow( HolidayRuleDto r ) => + r.WindowStart is not null && r.WindowEnd is not null + ? $"{r.WindowStart} – {r.WindowEnd} ({r.WindowTimeZoneId})" + : "Full day"; + + private static string FormatYearRange( HolidayRuleDto r ) => + (r.YearStart, r.YearEnd) switch { + (not null, not null) => $"{r.YearStart}–{r.YearEnd}", + (not null, null) => $"{r.YearStart}+", + (null, not null) => $"–{r.YearEnd}", + _ => "All years" + }; +} diff --git a/src/Werkr.Server/Components/Pages/HolidayCalendars/Edit.razor b/src/Werkr.Server/Components/Pages/HolidayCalendars/Edit.razor new file mode 100644 index 0000000..c0f7f3d --- /dev/null +++ b/src/Werkr.Server/Components/Pages/HolidayCalendars/Edit.razor @@ -0,0 +1,502 @@ +@page "/holiday-calendars/{Id:guid}" +@using System.ComponentModel.DataAnnotations +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation +@using Werkr.Common.Models.Holidays + +Werkr - Edit Holiday Calendar + +

Edit Holiday Calendar

+ + +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} +@if ( !string.IsNullOrWhiteSpace( _successMessage ) ) { + +} + +@if ( _isLoading ) { + +} else if ( _calendar is not null ) { + + @* ── Calendar Info Card ── *@ +
+
+
Calendar Information
+ + +
+ + +
+
+ + +
+ +
+
+
+ + @* ── Rules Card ── *@ +
+
+
Holiday Rules
+ @if ( _rules is not null && _rules.Count > 0 ) { +
+ + + + + + + + + + + + + @foreach ( HolidayRuleDto rule in _rules ) { + + + + + + + + + } + +
NameTypeConfigObservanceTime WindowActions
@rule.Name@rule.RuleType@DescribeRuleConfig( rule )@rule.ObservanceRule@FormatWindow( rule ) + + +
+
+ } else { +

No rules defined yet.

+ } + + @* Add Rule inline form *@ +
+
Add Rule
+
+
+ + +
+
+ + +
+
+ + +
+ @if ( _newRuleType == "FixedDate" ) { +
+ + +
+
+ + +
+ } else { +
+ + +
+ @if ( _newRuleType == "NthWeekdayOfMonth" ) { +
+ + +
+ } + } +
+ +
+
+
+ + @* ── Rule Preview (conditional) ── *@ + @if ( _rulePreviewDates is not null ) { +
+
+
Rule Preview: @_rulePreviewName
+ @if ( _rulePreviewDates.Count > 0 ) { +
+ @foreach ( HolidayDateDto d in _rulePreviewDates ) { +
+ @d.Date — @d.Name +
+ } +
+ } else { +

No dates generated for the preview range.

+ } + +
+
+ } + + @* ── Manual Dates Card ── *@ +
+
+
Manual Dates
+ @if ( _manualDates is not null && _manualDates.Count > 0 ) { +
+ + + + + + + + + + + @foreach ( HolidayDateDto date in _manualDates ) { + + + + + + + } + +
DateNameTime WindowActions
@date.Date@date.Name@FormatDateWindow( date ) + +
+
+ } else { +

No manual dates added.

+ } + +
+
Add Manual Date
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + @* ── Calendar Preview Card ── *@ +
+
+
Calendar Preview
+
+
+ + +
+
+ + +
+
+ +
+
+ + @if ( _previewDates is not null ) { + @if ( _previewDates.Count > 0 ) { +
+ + + + @foreach ( HolidayDateDto d in _previewDates ) { + + + + + + } + +
DateNameSource
@d.Date@d.Name@( d.IsManual ? "Manual" : "Rule" )
+
+ } else { +

No dates found for the selected range.

+ } + } +
+
+ + + +} + + + +@code { + [Parameter] public Guid Id { get; set; } + + private HolidayCalendarDto? _calendar; + private List? _rules; + private List? _manualDates; + private string? _errorMessage; + private string? _successMessage; + private bool _isLoading; + + // Info form model + private InfoModel _infoModel = new( ); + + // Add Rule fields + private string _newRuleName = ""; + private string _newRuleType = "FixedDate"; + private int _newRuleMonth = 1; + private int? _newRuleDay = 1; + private string _newRuleObservance = "None"; + private int _newRuleDayOfWeek; + private int _newRuleWeekNumber = 1; + + // Rule preview + private string? _rulePreviewName; + private List? _rulePreviewDates; + + // Manual date fields + private DateOnly _newDateValue = DateOnly.FromDateTime( DateTime.Today ); + private string _newDateName = ""; + + // Calendar preview + private int _previewStartYear = DateTime.Now.Year; + private int _previewEndYear = DateTime.Now.Year + 1; + private List? _previewDates; + + // Delete confirm + private ConfirmDialog _confirmDialog = default!; + private long _pendingDeleteRuleId; + private string _deleteMessage = ""; + + private List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Holiday Calendars", "/holiday-calendars" ), + new( "Edit" ) + ]; + + protected override async Task OnInitializedAsync( ) { + await LoadAllAsync( ); + } + + private async Task LoadAllAsync( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _calendar = await client.GetFromJsonAsync( $"/api/holiday-calendars/{Id}" ); + + if ( _calendar is not null ) { + // Guard: system calendars go to detail page + if ( _calendar.IsSystemCalendar ) { + Navigation.NavigateTo( $"/holiday-calendars/{Id}/detail" ); + return; + } + _infoModel = new InfoModel { Name = _calendar.Name, Description = _calendar.Description }; + _breadcrumbs = [ + new( "Task List", "/" ), + new( "Holiday Calendars", "/holiday-calendars" ), + new( _calendar.Name ) + ]; + } + + _rules = await client.GetFromJsonAsync>( $"/api/holiday-calendars/{Id}/rules" ); + _manualDates = await client.GetFromJsonAsync>( $"/api/holiday-calendars/{Id}/dates" ); + } catch ( Exception ex ) { + _errorMessage = $"Failed to load calendar: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private async Task SaveInfoAsync( ) { + _errorMessage = null; + _successMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HolidayCalendarUpdateRequest req = new( _infoModel.Name, _infoModel.Description ?? "" ); + HttpResponseMessage res = await client.PutAsJsonAsync( $"/api/holiday-calendars/{Id}", req ); + if ( res.IsSuccessStatusCode ) { + _successMessage = "Calendar info saved."; + _calendar = await res.Content.ReadFromJsonAsync( ); + } else { + _errorMessage = $"Save failed: HTTP {(int) res.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Save failed: {ex.Message}"; + } + } + + private async Task AddRuleAsync( ) { + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HolidayRuleCreateRequest req = new( + Name: _newRuleName, + RuleType: _newRuleType, + Month: _newRuleMonth, + Day: _newRuleType == "FixedDate" ? _newRuleDay : null, + DayOfWeek: _newRuleType != "FixedDate" ? ((DayOfWeek) _newRuleDayOfWeek).ToString( ) : null, + WeekNumber: _newRuleType == "NthWeekdayOfMonth" ? _newRuleWeekNumber : null, + ObservanceRule: _newRuleType == "FixedDate" ? _newRuleObservance : "None", + WindowStart: null, WindowEnd: null, WindowTimeZoneId: null, + YearStart: null, YearEnd: null ); + HttpResponseMessage res = await client.PostAsJsonAsync( $"/api/holiday-calendars/{Id}/rules", req ); + if ( res.IsSuccessStatusCode ) { + _newRuleName = ""; + await LoadAllAsync( ); + } else { + _errorMessage = $"Failed to add rule: HTTP {(int) res.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to add rule: {ex.Message}"; + } + } + + private void PromptDeleteRule( HolidayRuleDto rule ) { + _pendingDeleteRuleId = rule.Id; + _deleteMessage = $"Remove rule '{rule.Name}'?"; + _confirmDialog.Show( ); + } + + private async Task DeleteRuleConfirmedAsync( ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage res = await client.DeleteAsync( $"/api/holiday-calendars/{Id}/rules/{_pendingDeleteRuleId}" ); + if ( res.IsSuccessStatusCode ) { + await LoadAllAsync( ); + } else { + _errorMessage = $"Failed to remove rule: HTTP {(int) res.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to remove rule: {ex.Message}"; + } + } + + private async Task PreviewRuleAsync( HolidayRuleDto rule ) { + _rulePreviewName = rule.Name; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + RulePreviewRequest req = new( + Name: rule.Name, RuleType: rule.RuleType, + Month: rule.Month, Day: rule.Day, + DayOfWeek: rule.DayOfWeek, WeekNumber: rule.WeekNumber, + ObservanceRule: rule.ObservanceRule, + WindowStart: rule.WindowStart, WindowEnd: rule.WindowEnd, + WindowTimeZoneId: rule.WindowTimeZoneId, + YearStart: rule.YearStart, YearEnd: rule.YearEnd ); + HttpResponseMessage res = await client.PostAsJsonAsync( + $"/api/holiday-calendars/rules/preview?startYear={_previewStartYear}&endYear={_previewEndYear}", req ); + if ( res.IsSuccessStatusCode ) { + RulePreviewResponse? preview = await res.Content.ReadFromJsonAsync( ); + _rulePreviewDates = preview?.Dates?.ToList( ) ?? []; + } + } catch ( Exception ex ) { + _errorMessage = $"Preview failed: {ex.Message}"; + } + } + + private async Task AddDateAsync( ) { + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HolidayDateCreateRequest req = new( _newDateValue.ToString( "yyyy-MM-dd" ), _newDateName, null, null, null ); + HttpResponseMessage res = await client.PostAsJsonAsync( $"/api/holiday-calendars/{Id}/dates", req ); + if ( res.IsSuccessStatusCode ) { + _newDateName = ""; + await LoadAllAsync( ); + } else { + _errorMessage = $"Failed to add date: HTTP {(int) res.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to add date: {ex.Message}"; + } + } + + private async Task RemoveDateAsync( long dateId ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage res = await client.DeleteAsync( $"/api/holiday-calendars/{Id}/dates/{dateId}" ); + if ( res.IsSuccessStatusCode ) { + await LoadAllAsync( ); + } else { + _errorMessage = $"Failed to remove date: HTTP {(int) res.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to remove date: {ex.Message}"; + } + } + + private async Task PreviewCalendarAsync( ) { + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HolidayPreviewResponse? preview = await client.GetFromJsonAsync( + $"/api/holiday-calendars/{Id}/preview?startYear={_previewStartYear}&endYear={_previewEndYear}" ); + _previewDates = preview?.Dates?.ToList( ); + } catch ( Exception ex ) { + _errorMessage = $"Preview failed: {ex.Message}"; + } + } + + private static string DescribeRuleConfig( HolidayRuleDto rule ) => rule.RuleType switch { + "FixedDate" => $"Month {rule.Month}, Day {rule.Day}", + "NthWeekdayOfMonth" => $"#{rule.WeekNumber} {rule.DayOfWeek} of month {rule.Month}", + "LastWeekdayOfMonth" => $"Last {rule.DayOfWeek} of month {rule.Month}", + _ => "—" + }; + + private static string FormatWindow( HolidayRuleDto rule ) => + rule.WindowStart is not null && rule.WindowEnd is not null + ? $"{rule.WindowStart} – {rule.WindowEnd} ({rule.WindowTimeZoneId})" + : "Full day"; + + private static string FormatDateWindow( HolidayDateDto d ) => + d.WindowStart is not null && d.WindowEnd is not null + ? $"{d.WindowStart} – {d.WindowEnd}" + : "Full day"; + + private sealed class InfoModel { + [Required] [StringLength( 256, MinimumLength = 1 )] + public string Name { get; set; } = ""; + public string? Description { get; set; } + } +} diff --git a/src/Werkr.Server/Components/Pages/HolidayCalendars/Index.razor b/src/Werkr.Server/Components/Pages/HolidayCalendars/Index.razor new file mode 100644 index 0000000..de9db62 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/HolidayCalendars/Index.razor @@ -0,0 +1,153 @@ +@page "/holiday-calendars" +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation +@using Werkr.Common.Models.Holidays + +Werkr - Holiday Calendars + +

Holiday Calendars

+ + +

Manage holiday calendars used to filter schedule occurrences.

+ +
+ + New Calendar + + +
+ +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading && _calendars is null ) { + +} else if ( _calendars is not null && _calendars.Count > 0 ) { +
+ + + + + + + + + + + + + @foreach ( HolidayCalendarSummaryDto cal in _calendars ) { + + + + + + + + + } + +
NameDescriptionTypeRulesAttached SchedulesActions
+ @cal.Name + @( string.IsNullOrWhiteSpace( cal.Description ) ? "—" : cal.Description ) + @if ( cal.IsSystemCalendar ) { + System + } else { + Custom + } + @cal.RuleCount@cal.AttachedScheduleCount + @if ( cal.IsSystemCalendar ) { + View + + } else { + Edit + + } +
+
+} else if ( !_isLoading ) { + +} + + + +@code { + private List? _calendars; + private string? _errorMessage; + private bool _isLoading; + private ConfirmDialog _confirmDialog = default!; + private Guid _pendingDeleteId; + private string _deleteMessage = ""; + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Holiday Calendars" ) + ]; + + protected override async Task OnInitializedAsync( ) { + await LoadAsync( ); + } + + private async Task LoadAsync( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _calendars = await client.GetFromJsonAsync>( "/api/holiday-calendars" ); + } catch ( Exception ex ) { + _errorMessage = $"Failed to load calendars: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private void PromptDelete( HolidayCalendarSummaryDto cal ) { + _pendingDeleteId = cal.Id; + _deleteMessage = $"Are you sure you want to delete calendar '{cal.Name}'? This will remove all rules, dates, and schedule attachments."; + _confirmDialog.Show( ); + } + + private async Task DeleteConfirmedAsync( ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.DeleteAsync( $"/api/holiday-calendars/{_pendingDeleteId}" ); + if ( response.IsSuccessStatusCode ) { + await LoadAsync( ); + } else { + _errorMessage = $"Failed to delete calendar: HTTP {(int) response.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to delete calendar: {ex.Message}"; + } + } + + private async Task CloneAsync( HolidayCalendarSummaryDto cal ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + CloneHolidayCalendarRequest req = new( $"{cal.Name} (Copy)" ); + HttpResponseMessage response = await client.PostAsJsonAsync( $"/api/holiday-calendars/{cal.Id}/clone", req ); + if ( response.IsSuccessStatusCode ) { + HolidayCalendarDto? cloned = await response.Content.ReadFromJsonAsync( ); + if ( cloned is not null ) { + Navigation.NavigateTo( $"/holiday-calendars/{cloned.Id}" ); + return; + } + } + _errorMessage = $"Failed to clone calendar: HTTP {(int) response.StatusCode}"; + } catch ( Exception ex ) { + _errorMessage = $"Failed to clone calendar: {ex.Message}"; + } + } +} diff --git a/src/Werkr.Server/Components/Pages/Home.razor b/src/Werkr.Server/Components/Pages/Home.razor new file mode 100644 index 0000000..89492bc --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Home.razor @@ -0,0 +1,487 @@ +@page "/overview" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using System.Diagnostics +@using Werkr.Common.Models +@using Werkr.Server.Services +@using Werkr.Server.Helpers +@attribute [Authorize] +@rendermode InteractiveServer +@implements IAsyncDisposable +@inject IHttpClientFactory HttpClientFactory +@inject AuthenticationStateProvider AuthState +@inject IConfiguration Configuration +@inject ServerConfigCache ConfigCache + +Werkr - Dashboard + +
+

Welcome to Werkr

+ + +

Automation platform for scheduling and executing tasks across distributed agents.

+ +
+ +
+ + @if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { +
@_errorMessage
+ } + +
+ +
+
+
+
System Info
+
+
Server Name
+
@_serverName
+ +
Uptime
+
@GetUptime()
+ +
.NET Version
+
@Environment.Version
+ +
Current User
+
@_currentUserDisplay
+ +
Database
+
+ @if ( _databaseHealthy.HasValue ) { + + @(_databaseHealthy.Value ? "Connected" : "Unavailable") + + } else { + Unknown + } +
+ +
Registration Policy
+
+ + @(_allowRegistration ? "Open" : "Closed") + +
+
+
+
+
+
+ + @* ── Automation Summary ── *@ +
+
+
+
+
Schedules
+

@_scheduleCount

+ Manage + Calendar +
+
+
+
+
+
+
Tasks
+

@_taskCount

+ Manage + Overview +
+
+
+
+
+
+
Workflows
+

@_workflowCount

+ Manage + Jobs +
+
+
+
+ + + +
+
+
Agent Health
+ @if ( _health.Count == 0 ) { +

No health data available.

+ } else { +
+ + + + + + + + + + + + @foreach ( AgentHealthDto item in _health ) { + + + + + + + + } + +
AgentStatusPowerShellSystemShellLast Seen
@item.ConnectionName@item.Status@FormatAvailability( item.PowerShellAvailable )@FormatAvailability( item.SystemShellAvailable )@FormatRelativeTime( item.LastSeen )
+
+ } +
+
+ +
+
+
Recent Activity
+ @if ( _activity.Count == 0 ) { +

No recent activity.

+ } else { +
    + @foreach ( AgentActivityDto entry in _activity ) { +
  • + @entry.ConnectionName — @entry.EventType + (@FormatRelativeTime( entry.OccurredAtUtc )) +
  • + } +
+ } +
+
+ + @* ── Recent Job Activity ── *@ +
+
+
Recent Job Activity
+ @if ( _recentJobs.Count == 0 ) { +

No recent jobs.

+ } else { +
+ + + + + + + + + + + + @foreach ( JobListDto job in _recentJobs ) { + + + + + + + + } + +
JobTaskResultRuntimeStarted
@job.Id.ToString( )[..8]…Task #@job.TaskId + + @( job.Success ? "Success" : "Failed" ) + + @if ( !job.Success && !string.IsNullOrWhiteSpace( job.ErrorCategory ) ) { + @job.ErrorCategory + } + @job.RuntimeSeconds.ToString( "F1" )s@FormatRelativeTime( job.StartTime )
+
+ View All Jobs + } +
+
+ + @* ── Schedule Health ── *@ +
+
+
Schedule Health
+ @if ( _schedules.Count == 0 ) { +

No schedules configured.

+ } else { +
+
+ Active: @ActiveScheduleCount +
+
+ Expired: @ExpiredScheduleCount +
+
+ Total: @_schedules.Count +
+
+ @if ( ActiveScheduleCount > 0 ) { +

+ @ActiveScheduleCount schedule(s) actively firing. + @if ( ExpiredScheduleCount > 0 ) { + @ExpiredScheduleCount expired — consider cleanup. + } +

+ } + } +
+
+ + @* ── Workflow Runs ── *@ +
+
+
Workflow Runs
+ @if ( _recentRuns.Count == 0 ) { +

No workflow runs recorded.

+ } else { +
+ + + + + + + + + + + + @foreach ( WorkflowRunDto run in _recentRuns ) { + + + + + + + + } + +
RunWorkflowStatusStartedDuration
@run.Id.ToString( )[..8]…Workflow #@run.WorkflowId + @run.Status + @FormatRelativeTime( run.StartTime ) + @if ( run.EndTime.HasValue ) { + @( ( run.EndTime.Value - run.StartTime ).TotalSeconds.ToString( "F1" ) )@:s + } else { + Running… + } +
+
+ } +
+
+
+ +@code { + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Overview" ) + ]; + private readonly DateTime _processStartUtc = Process.GetCurrentProcess( ).StartTime.ToUniversalTime( ); + + private List _agents = []; + private List _health = []; + private List _activity = []; + private List _databaseHealth = []; + private List _recentJobs = []; + private List _recentRuns = []; + private List _schedules = []; + private int _scheduleCount; + private int _taskCount; + private int _workflowCount; + private bool _isLoading; + + private int ActiveScheduleCount => _schedules.Count( s => + s.Expiration is null || s.Expiration.Date > DateOnly.FromDateTime( DateTime.UtcNow ) ); + private int ExpiredScheduleCount => _schedules.Count - ActiveScheduleCount; + private string? _errorMessage; + private string _serverName = "Werkr Server"; + private bool _allowRegistration = true; + private bool? _databaseHealthy; + private string _currentUserDisplay = "Unknown"; + + private PeriodicTimer? _refreshTimer; + private CancellationTokenSource? _refreshCancellationTokenSource; + private bool _disposed; + + protected override async Task OnInitializedAsync( ) { + _serverName = Configuration["Server:Name"] ?? "Werkr Server"; + _allowRegistration = bool.TryParse( Configuration["Server:AllowRegistration"], out bool allowRegistration ) + ? allowRegistration + : true; + + await RefreshAsync( ); + + _refreshCancellationTokenSource = new CancellationTokenSource( ); + _refreshTimer = new PeriodicTimer( TimeSpan.FromSeconds( ConfigCache.PollingIntervalSeconds ) ); + _ = RefreshLoopAsync( _refreshCancellationTokenSource.Token ); + } + + private async Task RefreshLoopAsync( CancellationToken cancellationToken ) { + if ( _refreshTimer is null ) { + return; + } + + try { + while ( await _refreshTimer.WaitForNextTickAsync( cancellationToken ) ) { + if ( _disposed ) { + return; + } + + await RefreshAsync( ); + await InvokeAsync( StateHasChanged ); + } + } catch ( OperationCanceledException ) { + } catch ( ObjectDisposedException ) { + } + } + + private async Task RefreshAsync( ) { + _isLoading = true; + _errorMessage = null; + + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + + Task?> agentsTask = client.GetFromJsonAsync>( "/api/agents" ); + Task?> healthTask = client.GetFromJsonAsync>( "/api/agents/health" ); + Task?> activityTask = client.GetFromJsonAsync>( "/api/agents/activity?count=10" ); + Task?> databaseTask = client.GetFromJsonAsync>( "/api/diagnostics/health" ); + Task?> schedulesTask = client.GetFromJsonAsync>( "/api/schedules" ); + Task?> tasksTask = client.GetFromJsonAsync>( "/api/tasks" ); + Task?> workflowsTask = client.GetFromJsonAsync>( "/api/workflows" ); + Task?> recentJobsTask = client.GetFromJsonAsync>( "/api/jobs?limit=10" ); + Task authTask = AuthState.GetAuthenticationStateAsync( ); + + await Task.WhenAll( agentsTask, healthTask, activityTask, databaseTask, schedulesTask, tasksTask, workflowsTask, recentJobsTask, authTask ); + + _agents = await agentsTask ?? []; + _health = await healthTask ?? []; + _activity = await activityTask ?? []; + _databaseHealth = await databaseTask ?? []; + _schedules = await schedulesTask ?? []; + _scheduleCount = _schedules.Count; + _taskCount = ( await tasksTask )?.Count ?? 0; + _recentJobs = await recentJobsTask ?? []; + + List? workflows = await workflowsTask; + _workflowCount = workflows?.Count ?? 0; + + // Fetch recent runs for each workflow (capped to keep it fast) + List allRuns = []; + if ( workflows is { Count: > 0 } ) { + IEnumerable?>> runTasks = workflows.Take( 20 ).Select( w => + client.GetFromJsonAsync>( $"/api/workflows/{w.Id}/runs" ) ); + List?[] results = await Task.WhenAll( runTasks ); + foreach ( List? runs in results ) { + if ( runs is not null ) allRuns.AddRange( runs ); + } + } + _recentRuns = allRuns.OrderByDescending( r => r.StartTime ).Take( 10 ).ToList( ); + + _databaseHealthy = _databaseHealth.Count > 0 && _databaseHealth.All( item => item.IsConnected ); + + AuthenticationState authState = await authTask; + string role = authState.User.IsInRole( "Admin" ) + ? "Admin" + : authState.User.IsInRole( "Operator" ) + ? "Operator" + : "Viewer"; + _currentUserDisplay = $"{authState.User.Identity?.Name ?? "Unknown"} ({role})"; + } catch ( Exception ex ) { + _errorMessage = $"Failed to load dashboard data: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private int CountByStatus( string status ) { + return _agents.Count( agent => string.Equals( agent.Status, status, StringComparison.OrdinalIgnoreCase ) ); + } + + private string GetUptime( ) { + TimeSpan uptime = DateTime.UtcNow - _processStartUtc; + if ( uptime.TotalDays >= 1 ) { + return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m"; + } + + if ( uptime.TotalHours >= 1 ) { + return $"{uptime.Hours}h {uptime.Minutes}m"; + } + + return $"{Math.Max( 0, uptime.Minutes )}m"; + } + + private static string FormatAvailability( bool? value ) => AgentDisplayHelper.FormatAvailability( value ); + private static string FormatRelativeTime( DateTime? timestamp ) => AgentDisplayHelper.FormatRelativeTime( timestamp ); + private static string GetStatusBadgeClass( string status ) => AgentDisplayHelper.GetStatusBadgeClass( status ); + + private static string GetRunStatusBadge( string status ) { + return status switch { + "Completed" or "Succeeded" => "bg-success", + "Failed" or "Faulted" => "bg-danger", + "Running" or "InProgress" => "bg-primary", + "Cancelled" or "Canceled" => "bg-warning text-dark", + _ => "bg-secondary" + }; + } + + public ValueTask DisposeAsync( ) { + _disposed = true; + + if ( _refreshCancellationTokenSource is not null ) { + _refreshCancellationTokenSource.Cancel( ); + _refreshCancellationTokenSource.Dispose( ); + _refreshCancellationTokenSource = null; + } + + if ( _refreshTimer is not null ) { + _refreshTimer.Dispose( ); + _refreshTimer = null; + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Werkr.Server/Components/Pages/Jobs/Detail.razor b/src/Werkr.Server/Components/Pages/Jobs/Detail.razor new file mode 100644 index 0000000..cc6ce97 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Jobs/Detail.razor @@ -0,0 +1,133 @@ +@page "/jobs/{Id:guid}" +@rendermode InteractiveServer +@using Werkr.Common.Rendering +@attribute [Authorize] +@inject IHttpClientFactory HttpClientFactory + +Werkr - Job Detail + +

Job Detail

+ + +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading ) { + +} else if ( _job is not null ) { +
+
+
Summary
+
+
Job ID
+
@_job.Id
+ +
Task ID
+
@_job.TaskId
+ +
Result
+
+ + @( _job.Success ? "Success" : "Failed" ) + +
+ +
Exit Code
+
@( _job.ExitCode?.ToString( ) ?? "—" )
+ +
Error Category
+
@_job.ErrorCategory
+ +
Runtime
+
@_job.RuntimeSeconds.ToString( "F2" ) seconds
+ +
Started
+
@_job.StartTime.ToString( "G" )
+ +
Ended
+
@( _job.EndTime?.ToString( "G" ) ?? "Still running" )
+ +
Agent
+
+ @if ( _job.AgentConnectionId is not null ) { + @_job.AgentConnectionId.Value.ToString()[..8]… + } else { + + } +
+
+
+
+ +
+
+
Output
+ +
+
+ @if ( !string.IsNullOrWhiteSpace( _output ) ) { +
@((MarkupString)AnsiHtmlConverter.Convert( _output ))
+ } else if ( !string.IsNullOrWhiteSpace( _job.Output ) ) { +
@((MarkupString)AnsiHtmlConverter.Convert( _job.Output ))
+ } else { +

No output available.

+ } +
+
+ + Back +} + +@code { + [Parameter] public Guid Id { get; set; } + + private JobDto? _job; + private string? _output; + private string? _errorMessage; + private bool _isLoading; + private bool _isLoadingOutput; + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Jobs", "/jobs" ), + new( "Detail" ) + ]; + + protected override async Task OnInitializedAsync( ) { + _isLoading = true; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _job = await client.GetFromJsonAsync( $"/api/jobs/{Id}" ); + if ( _job is null ) { + _errorMessage = "Job not found."; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to load job: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private async Task LoadOutputAsync( ) { + _isLoadingOutput = true; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.GetAsync( $"/api/jobs/{Id}/output" ); + if ( response.IsSuccessStatusCode ) { + _output = await response.Content.ReadAsStringAsync( ); + } else { + _errorMessage = "No output file available."; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to load output: {ex.Message}"; + } finally { + _isLoadingOutput = false; + } + } +} diff --git a/src/Werkr.Server/Components/Pages/Jobs/Index.razor b/src/Werkr.Server/Components/Pages/Jobs/Index.razor new file mode 100644 index 0000000..d4ffcd6 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Jobs/Index.razor @@ -0,0 +1,176 @@ +@page "/jobs" +@rendermode InteractiveServer +@attribute [Authorize] +@inject IHttpClientFactory HttpClientFactory + +Werkr - Jobs + +

Job History

+ + +

Browse execution history across all tasks. Use the filters to narrow results.

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading && _jobs is null ) { + +} else if ( _jobs is not null && _jobs.Count > 0 ) { +
+ + + + + + + + + + + + + + + @foreach ( JobListDto j in _jobs ) { + + + + + + + + + + + } + +
Job IDTaskAgentStartedRuntimeResultError CategoryActions
@j.Id.ToString()[..8]…@( j.TaskName ?? $"#{j.TaskId}" )@( j.AgentName ?? "—" )@j.StartTime.ToString( "g" )@j.RuntimeSeconds.ToString( "F1" )s + @{ bool isRunning = j.EndTime is null && !j.Success; } + @if ( isRunning ) { + + Running + + } else { + + @( j.Success ? "Success" : "Failed" ) + + } + @j.ErrorCategory + Details +
+
+
+ Showing @_jobs.Count job(s) +
+} else if ( !_isLoading && _jobs is not null ) { + +} + +@code { + private List? _jobs; + private string? _errorMessage; + private bool _isLoading; + private long? _taskIdFilter; + private string _statusFilter = ""; + private DateTime? _sinceFilter; + private DateTime? _untilFilter; + private int _limit = 50; + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Jobs" ) + ]; + + protected override async Task OnInitializedAsync( ) { + await LoadAsync( ); + } + + private async Task LoadAsync( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + + if ( _taskIdFilter.HasValue ) { + // Use task-specific endpoint when a task ID is provided + _jobs = await client.GetFromJsonAsync>( + $"/api/tasks/{_taskIdFilter}/jobs?limit={_limit}" ); + } else { + // Use global jobs endpoint + string url = BuildGlobalJobsUrl( ); + _jobs = await client.GetFromJsonAsync>( url ); + } + + // Client-side running filter — no server param for this + if ( _statusFilter == "running" && _jobs is not null ) { + _jobs = _jobs.Where( j => j.EndTime is null && !j.Success ).ToList( ); + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to load jobs: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private string BuildGlobalJobsUrl( ) { + List queryParams = [$"limit={_limit}"]; + + if ( _statusFilter == "success" ) queryParams.Add( "success=true" ); + else if ( _statusFilter == "failed" ) queryParams.Add( "success=false" ); + + if ( _sinceFilter.HasValue ) + queryParams.Add( $"since={_sinceFilter.Value:O}" ); + if ( _untilFilter.HasValue ) + queryParams.Add( $"until={_untilFilter.Value:O}" ); + + return $"/api/jobs?{string.Join( "&", queryParams )}"; + } + + private void ClearFilters( ) { + _taskIdFilter = null; + _statusFilter = ""; + _sinceFilter = null; + _untilFilter = null; + _limit = 50; + } +} diff --git a/src/Werkr.Server/Components/Pages/Register.razor b/src/Werkr.Server/Components/Pages/Register.razor new file mode 100644 index 0000000..35fafdd --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Register.razor @@ -0,0 +1,214 @@ +@page "/agents/register" +@rendermode InteractiveServer +@using Microsoft.AspNetCore.Authorization +@using Werkr.Common.Models +@attribute [Authorize( Roles = "Admin" )] +@inject IHttpClientFactory HttpClientFactory +@inject IConfiguration Configuration + +Werkr - Register Agent + +

Register New Agent

+

Generate a registration bundle to pair a new Agent with this Server.

+ +
+
+ + + +
+ + + +
+ +
+ + + +
+ +
+ + + @if ( _passwordMismatch ) { +
Passwords do not match.
+ } +
+ +
+ + + + + + + +
+ +
+ + +
Comma-separated tags to assign to the agent upon registration.
+
+ +
+ Server URL + @_serverUrl +
The Server's gRPC endpoint URL embedded in the bundle.
+
+ + +
+
+
+ +@if ( !string.IsNullOrEmpty( _errorMessage ) ) { +
@_errorMessage
+} + +@if ( !string.IsNullOrEmpty( _generatedBundle ) ) { +
+
+ Registration Bundle Generated +
+
+
+ + +
+ + @if ( _copied ) { + Copied! + } +
+

+ Copy this bundle and paste it into your Agent's registration page at + http://localhost:<port>/register. + @if ( _model!.ExpirationHours > 0 ) { + The bundle expires in @FormatExpiration( _model!.ExpirationHours ). + } else { + This bundle does not expire. + } +

+
+
+} + + + +@code { + [SupplyParameterFromForm] + private RegisterFormModel? _model { get; set; } + + private string _serverUrl = string.Empty; + private string? _generatedBundle; + private string? _errorMessage; + private bool _isGenerating; + private bool _passwordMismatch; + private bool _copied; + + /// + protected override void OnInitialized( ) { + _model ??= new( ) { ExpirationHours = 24 }; + _serverUrl = Configuration.GetValue( "Werkr:ServerUrl" ) ?? "https://localhost:7001"; + } + + private async Task HandleGenerate( ) { + _errorMessage = null; + _generatedBundle = null; + _copied = false; + + // Validate password match + if ( _model!.Password != _model.ConfirmPassword ) { + _passwordMismatch = true; + return; + } + _passwordMismatch = false; + + _isGenerating = true; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + + int? expirationMinutes = _model.ExpirationHours > 0 + ? _model.ExpirationHours * 60 + : 0; + + string[]? tags = string.IsNullOrWhiteSpace( _model.Tags ) + ? null + : _model.Tags.Split( ',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ); + + RegistrationGenerateRequest request = new( + ConnectionName: _model.ConnectionName, + Password: _model.Password, + ExpirationMinutes: expirationMinutes, + Tags: tags ); + + HttpResponseMessage response = await client.PostAsJsonAsync( + "/api/registration/generate", + request ); + + if ( response.IsSuccessStatusCode ) { + RegistrationGenerateResponse? result = + await response.Content.ReadFromJsonAsync( ); + if ( result is not null && result.Success ) { + _generatedBundle = result.EncryptedBundle; + } else { + _errorMessage = result?.Message ?? "Unknown error generating bundle."; + } + } else { + _errorMessage = $"API returned {(int) response.StatusCode}: {await response.Content.ReadAsStringAsync( )}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to contact the API service: {ex.Message}"; + } finally { + _isGenerating = false; + } + } + + private async Task CopyToClipboard( ) { + // Intentionally left empty — JS interop for clipboard is optional + // The textarea is selectable for manual copy + _copied = true; + await Task.CompletedTask; + } + + private static string FormatExpiration( int hours ) { + return hours switch { + 1 => "1 hour", + 24 => "24 hours", + 168 => "7 days", + _ => $"{hours} hours" + }; + } + + private sealed class RegisterFormModel { + [System.ComponentModel.DataAnnotations.Required( ErrorMessage = "Connection name is required." )] + public string ConnectionName { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required( ErrorMessage = "Password is required." )] + public string Password { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required( ErrorMessage = "Please confirm the password." )] + public string ConfirmPassword { get; set; } = string.Empty; + + public int ExpirationHours { get; set; } = 24; + + public string Tags { get; set; } = string.Empty; + } + +} diff --git a/src/Werkr.Server/Components/Pages/Schedules/Create.razor b/src/Werkr.Server/Components/Pages/Schedules/Create.razor new file mode 100644 index 0000000..edb58a9 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Schedules/Create.razor @@ -0,0 +1,456 @@ +@page "/schedules/create" +@using System.ComponentModel.DataAnnotations +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation +@using Werkr.Common.Models.Holidays + +Werkr - New Schedule + +

Create Schedule

+ + +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + + + + + +
+
+
Basic Information
+ +
+ + +
+ +
+
+ + +
+
+
+
+ +
+
+
Start Date & Time
+
+
+ + +
+
+ + +
+
+ + + @foreach ( TimeZoneInfo zone in s_timeZones ) { + + } + +
+
+
+
+ +
+
+
Recurrence
+ +
+ + + + + + + +
+ + @if ( _model.RecurrenceType == "Daily" ) { +
+ + +
+ } + + @if ( _model.RecurrenceType == "Weekly" ) { +
+
+ + +
+
+ +
+ @foreach ( (string label, int flag) in s_daysOfWeek ) { +
+ + +
+ } +
+
+
+ } + + @if ( _model.RecurrenceType == "Monthly" ) { +
+ +
+ @foreach ( (string label, int flag) in s_months ) { +
+ + +
+ } +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ + @if ( _model.MonthlyMode == "DayNumbers" ) { +
+ + +
Negative values count from end of month (-1 = last day).
+
+ } else { +
+ +
+ @foreach ( (string label, int flag) in s_weekNumbers ) { +
+ + +
+ } +
+
+
+ +
+ @foreach ( (string label, int flag) in s_daysOfWeek ) { +
+ + +
+ } +
+
+ } + } +
+
+ +
+
+
Expiration (optional)
+
+ + +
+ @if ( _model.HasExpiration ) { +
+
+ + +
+
+ + +
+
+ + + @foreach ( TimeZoneInfo zone in s_timeZones ) { + + } + +
+
+ } +
+
+ +
+
+
Repeat Options (optional)
+
+ + +
+ @if ( _model.HasRepeat ) { +
+
+ + +
+
+ + +
+
+ } +
+
+ + @* ── Holiday Calendar ── *@ +
+
+
Holiday Calendar
+

Optionally attach a holiday calendar to filter occurrences.

+ +
+
+ + +
+ @if ( !string.IsNullOrEmpty( _selectedCalendarId ) ) { +
+ + +
+ } +
+
+
+ +
+ + Cancel +
+
+ +
+
+
Occurrence Preview
+

Preview the next 10 occurrences based on the current settings.

+ + @if ( _previewOccurrences is not null && _previewOccurrences.Count > 0 ) { +
    + @foreach ( DateTime occ in _previewOccurrences ) { +
  1. @occ.ToLocalTime().ToString( "G" )
  2. + } +
+ } else if ( _previewOccurrences is not null ) { +

No occurrences found within the 90-day preview window.

+ } +
+
+ +@code { + private readonly ScheduleFormModel _model = new( ); + private string? _errorMessage; + private bool _isSaving; + private bool _isPreviewLoading; + private List? _previewOccurrences; + + // Holiday calendar attachment + private List? _availableCalendars; + private string _selectedCalendarId = ""; + private string _selectedCalendarMode = "Blocklist"; + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Schedules", "/schedules" ), + new( "Create" ) + ]; + + private static readonly (string Label, int Flag)[] s_daysOfWeek = [ + ("Sun", 1), ("Mon", 2), ("Tue", 4), ("Wed", 8), ("Thu", 16), ("Fri", 32), ("Sat", 64) + ]; + + private static readonly (string Label, int Flag)[] s_months = [ + ("Jan", 1), ("Feb", 2), ("Mar", 4), ("Apr", 8), + ("May", 16), ("Jun", 32), ("Jul", 64), ("Aug", 128), + ("Sep", 256), ("Oct", 512), ("Nov", 1024), ("Dec", 2048) + ]; + + private static readonly (string Label, int Flag)[] s_weekNumbers = [ + ("1st", 1), ("2nd", 2), ("3rd", 4), ("4th", 8), ("5th", 16), ("6th", 32) + ]; + + private static readonly IReadOnlyList s_timeZones = + TimeZoneInfo.GetSystemTimeZones( ).OrderBy( tz => tz.BaseUtcOffset ).ToList( ); + + protected override async Task OnInitializedAsync( ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _availableCalendars = await client.GetFromJsonAsync>( "/api/holiday-calendars" ); + } catch { + // Non-critical: calendar list not loaded + } + } + + private async Task SubmitAsync( ) { + _isSaving = true; + _errorMessage = null; + try { + ScheduleCreateRequest request = _model.ToCreateRequest( ); + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PostAsJsonAsync( "/api/schedules", request ); + if ( response.IsSuccessStatusCode ) { + // Attach holiday calendar if selected + if ( !string.IsNullOrEmpty( _selectedCalendarId ) && Guid.TryParse( _selectedCalendarId, out Guid calId ) ) { + ScheduleDto? created = await response.Content.ReadFromJsonAsync( ); + if ( created is not null ) { + AttachHolidayCalendarRequest attachReq = new( calId, _selectedCalendarMode ); + _ = await client.PutAsJsonAsync( $"/api/schedules/{created.Id}/holiday-calendar", attachReq ); + } + } + Navigation.NavigateTo( "/schedules" ); + } else { + string body = await response.Content.ReadAsStringAsync( ); + _errorMessage = $"Failed to create schedule: {body}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to create schedule: {ex.Message}"; + } finally { + _isSaving = false; + } + } + + private async Task ComputePreviewAsync( ) { + _isPreviewLoading = true; + _previewOccurrences = null; + try { + // Create a temporary schedule to get a preview via the API. + // We post the schedule, preview occurrences, and then delete it. + // Alternatively, compute locally. For now, compute client-side. + _previewOccurrences = SchedulePreviewCalculator.Calculate( _model.ToCreateRequest( ), 10 ); + } catch ( Exception ex ) { + _errorMessage = $"Preview failed: {ex.Message}"; + } finally { + _isPreviewLoading = false; + } + } + + private sealed class ScheduleFormModel { + [Required( ErrorMessage = "Name is required." )] + [StringLength( 200, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 200 characters." )] + public string Name { get; set; } = ""; + + [Range( 1, 1440, ErrorMessage = "Stop after must be between 1 and 1440 minutes." )] + public long StopTaskAfterMinutes { get; set; } = 60; + + [Required] + public DateOnly StartDate { get; set; } = DateOnly.FromDateTime( DateTime.Today ); + + [Required] + public TimeOnly StartTime { get; set; } = new( 8, 0 ); + + [Required( ErrorMessage = "Time zone is required." )] + public string TimeZoneId { get; set; } = TimeZoneInfo.Local.Id; + + public string RecurrenceType { get; set; } = "None"; + + [Range( 1, 365, ErrorMessage = "Day interval must be between 1 and 365." )] + public int DayInterval { get; set; } = 1; + + [Range( 1, 52, ErrorMessage = "Week interval must be between 1 and 52." )] + public int WeekInterval { get; set; } = 1; + + public int DaysOfWeek { get; set; } + public int MonthsOfYear { get; set; } + public string MonthlyMode { get; set; } = "DayNumbers"; + public string DayNumbers { get; set; } = ""; + public int MonthlyWeekNumber { get; set; } + public int MonthlyDaysOfWeek { get; set; } + public bool HasExpiration { get; set; } + public DateOnly ExpirationDate { get; set; } = DateOnly.FromDateTime( DateTime.Today.AddYears( 1 ) ); + public TimeOnly ExpirationTime { get; set; } = new( 23, 59 ); + public string ExpirationTimeZoneId { get; set; } = TimeZoneInfo.Local.Id; + public bool HasRepeat { get; set; } + + [Range( 1, 1440, ErrorMessage = "Repeat interval must be between 1 and 1440 minutes." )] + public int RepeatIntervalMinutes { get; set; } = 15; + + [Range( 1, 1440, ErrorMessage = "Repeat duration must be between 1 and 1440 minutes." )] + public int RepeatDurationMinutes { get; set; } = 60; + + public ScheduleCreateRequest ToCreateRequest( ) { + return new ScheduleCreateRequest( + Name: Name, + StopTaskAfterMinutes: StopTaskAfterMinutes, + StartDateTime: new StartDateTimeDto( StartDate, StartTime, TimeZoneId ), + Expiration: HasExpiration + ? new ExpirationDateTimeDto( ExpirationDate, ExpirationTime, ExpirationTimeZoneId ) + : null, + DailyRecurrence: RecurrenceType == "Daily" ? new DailyRecurrenceDto( DayInterval ) : null, + WeeklyRecurrence: RecurrenceType == "Weekly" ? new WeeklyRecurrenceDto( WeekInterval, DaysOfWeek ) : null, + MonthlyRecurrence: RecurrenceType == "Monthly" + ? new MonthlyRecurrenceDto( + MonthlyMode == "DayNumbers" ? ParseDayNumbers( DayNumbers ) : null, + MonthsOfYear, + MonthlyMode == "WeekAndDay" ? MonthlyWeekNumber : null, + MonthlyMode == "WeekAndDay" ? MonthlyDaysOfWeek : null ) + : null, + RepeatOptions: HasRepeat + ? new RepeatOptionsDto( RepeatIntervalMinutes, RepeatDurationMinutes ) + : null ); + } + + private static int[]? ParseDayNumbers( string input ) { + if ( string.IsNullOrWhiteSpace( input ) ) return null; + return input.Split( ',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ) + .Select( int.Parse ) + .ToArray( ); + } + } +} diff --git a/src/Werkr.Server/Components/Pages/Schedules/Edit.razor b/src/Werkr.Server/Components/Pages/Schedules/Edit.razor new file mode 100644 index 0000000..b9bb76d --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Schedules/Edit.razor @@ -0,0 +1,580 @@ +@page "/schedules/{Id:guid}" +@using System.ComponentModel.DataAnnotations +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation +@using Werkr.Common.Models.Holidays + +Werkr - Edit Schedule + +

Edit Schedule

+ + +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading ) { + +} else if ( _model is not null ) { + + + + +
+
+
Basic Information
+ +
+ + +
+ +
+
+ + +
+
+
+
+ +
+
+
Start Date & Time
+
+
+ + +
+
+ + +
+
+ + + @foreach ( TimeZoneInfo zone in s_timeZones ) { + + } + +
+
+
+
+ +
+
+
Recurrence
+ +
+ + + + + + + +
+ + @if ( _model.RecurrenceType == "Daily" ) { +
+ + +
+ } + + @if ( _model.RecurrenceType == "Weekly" ) { +
+
+ + +
+
+ +
+ @foreach ( (string label, int flag) in s_daysOfWeek ) { +
+ + +
+ } +
+
+
+ } + + @if ( _model.RecurrenceType == "Monthly" ) { +
+ +
+ @foreach ( (string label, int flag) in s_months ) { +
+ + +
+ } +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ + @if ( _model.MonthlyMode == "DayNumbers" ) { +
+ + +
Negative values count from end of month (-1 = last day).
+
+ } else { +
+ +
+ @foreach ( (string label, int flag) in s_weekNumbers ) { +
+ + +
+ } +
+
+
+ +
+ @foreach ( (string label, int flag) in s_daysOfWeek ) { +
+ + +
+ } +
+
+ } + } +
+
+ +
+
+
Expiration (optional)
+
+ + +
+ @if ( _model.HasExpiration ) { +
+
+ + +
+
+ + +
+
+ + + @foreach ( TimeZoneInfo zone in s_timeZones ) { + + } + +
+
+ } +
+
+ +
+
+
Repeat Options (optional)
+
+ + +
+ @if ( _model.HasRepeat ) { +
+
+ + +
+
+ + +
+
+ } +
+
+ + @* ── Holiday Calendar ── *@ +
+
+
Holiday Calendar
+
+
+ + +
+ @if ( !string.IsNullOrEmpty( _selectedCalendarId ) ) { +
+ + +
+ } +
+
+
+ +
+ + Cancel +
+
+ + @* ── Suppressed Occurrences (Audit Log) ── *@ + @if ( !string.IsNullOrEmpty( _selectedCalendarId ) ) { +
+
+
Suppressed Occurrences
+

Recent schedule occurrences filtered by the holiday calendar.

+ + @if ( _auditLogs is not null && _auditLogs.Count > 0 ) { +
+ + + + @foreach ( ScheduleAuditLogDto log in _auditLogs ) { + + + + + + + } + +
Date/Time (UTC)HolidayCalendarMode
@log.OccurrenceUtcTime.ToString( "G" )@log.HolidayName@log.CalendarName@log.Mode
+
+ } else if ( _auditLogs is not null ) { +

No suppressed occurrences recorded yet.

+ } +
+
+ } + +
+
+
Occurrence Preview
+

Preview the next 10 occurrences based on the current settings.

+ + @if ( _previewOccurrences is not null && _previewOccurrences.Count > 0 ) { +
    + @foreach ( DateTime occ in _previewOccurrences ) { +
  1. @occ.ToLocalTime().ToString( "G" )
  2. + } +
+ } else if ( _previewOccurrences is not null ) { +

No occurrences found within the preview window.

+ } +
+
+} + +@code { + [Parameter] public Guid Id { get; set; } + + private ScheduleFormModel? _model; + private string? _errorMessage; + private bool _isLoading; + private bool _isSaving; + private bool _isPreviewLoading; + private List? _previewOccurrences; + + // Holiday calendar + private List? _availableCalendars; + private string _selectedCalendarId = ""; + private string _selectedCalendarMode = "Blocklist"; + private List? _auditLogs; + + private List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Schedules", "/schedules" ), + new( "Edit" ) + ]; + + private static readonly (string Label, int Flag)[] s_daysOfWeek = [ + ("Sun", 1), ("Mon", 2), ("Tue", 4), ("Wed", 8), ("Thu", 16), ("Fri", 32), ("Sat", 64) + ]; + + private static readonly (string Label, int Flag)[] s_months = [ + ("Jan", 1), ("Feb", 2), ("Mar", 4), ("Apr", 8), + ("May", 16), ("Jun", 32), ("Jul", 64), ("Aug", 128), + ("Sep", 256), ("Oct", 512), ("Nov", 1024), ("Dec", 2048) + ]; + + private static readonly (string Label, int Flag)[] s_weekNumbers = [ + ("1st", 1), ("2nd", 2), ("3rd", 4), ("4th", 8), ("5th", 16), ("6th", 32) + ]; + + private static readonly IReadOnlyList s_timeZones = + TimeZoneInfo.GetSystemTimeZones( ).OrderBy( tz => tz.BaseUtcOffset ).ToList( ); + + protected override async Task OnInitializedAsync( ) { + _isLoading = true; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + ScheduleDto? dto = await client.GetFromJsonAsync( $"/api/schedules/{Id}" ); + if ( dto is null ) { + _errorMessage = "Schedule not found."; + return; + } + _breadcrumbs = [new( "Task List", "/" ), new( "Schedules", "/schedules" ), new( dto.Name )]; + _model = ScheduleFormModel.FromDto( dto ); + + // Load available calendars and current attachment + _availableCalendars = await client.GetFromJsonAsync>( "/api/holiday-calendars" ); + try { + ScheduleHolidayCalendarDto? link = await client.GetFromJsonAsync( + $"/api/schedules/{Id}/holiday-calendar" ); + if ( link is not null ) { + _selectedCalendarId = link.CalendarId.ToString( ); + _selectedCalendarMode = link.Mode; + await LoadAuditLogAsync( ); + } + } catch { + // No calendar attached — OK + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to load schedule: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private async Task SubmitAsync( ) { + if ( _model is null ) return; + _isSaving = true; + _errorMessage = null; + try { + ScheduleUpdateRequest request = _model.ToUpdateRequest( ); + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PutAsJsonAsync( $"/api/schedules/{Id}", request ); + if ( response.IsSuccessStatusCode ) { + // Update holiday calendar attachment + if ( !string.IsNullOrEmpty( _selectedCalendarId ) && Guid.TryParse( _selectedCalendarId, out Guid calId ) ) { + AttachHolidayCalendarRequest attachReq = new( calId, _selectedCalendarMode ); + _ = await client.PutAsJsonAsync( $"/api/schedules/{Id}/holiday-calendar", attachReq ); + } else { + _ = await client.DeleteAsync( $"/api/schedules/{Id}/holiday-calendar" ); + } + Navigation.NavigateTo( "/schedules" ); + } else { + string body = await response.Content.ReadAsStringAsync( ); + _errorMessage = $"Failed to update schedule: {body}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to update schedule: {ex.Message}"; + } finally { + _isSaving = false; + } + } + + private async Task ComputePreviewAsync( ) { + if ( _model is null ) return; + _isPreviewLoading = true; + _previewOccurrences = null; + try { + _previewOccurrences = SchedulePreviewCalculator.Calculate( _model.ToPreviewRequest( ), 10 ); + } catch ( Exception ex ) { + _errorMessage = $"Preview failed: {ex.Message}"; + } finally { + _isPreviewLoading = false; + } + } + + private async Task LoadAuditLogAsync( ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + DateTime from = DateTime.UtcNow.AddDays( -30 ); + DateTime to = DateTime.UtcNow; + string url = $"/api/schedules/{Id}/audit-log?from={from:O}&to={to:O}"; + _auditLogs = await client.GetFromJsonAsync>( url ) ?? []; + } catch { + _auditLogs = []; + } + } + + private sealed class ScheduleFormModel { + [Required( ErrorMessage = "Name is required." )] + [StringLength( 200, MinimumLength = 1, ErrorMessage = "Name must be between 1 and 200 characters." )] + public string Name { get; set; } = ""; + + [Range( 1, 1440, ErrorMessage = "Stop after must be between 1 and 1440 minutes." )] + public long StopTaskAfterMinutes { get; set; } = 60; + + [Required] + public DateOnly StartDate { get; set; } = DateOnly.FromDateTime( DateTime.Today ); + + [Required] + public TimeOnly StartTime { get; set; } = new( 8, 0 ); + + [Required( ErrorMessage = "Time zone is required." )] + public string TimeZoneId { get; set; } = TimeZoneInfo.Local.Id; + + public string RecurrenceType { get; set; } = "None"; + + [Range( 1, 365, ErrorMessage = "Day interval must be between 1 and 365." )] + public int DayInterval { get; set; } = 1; + + [Range( 1, 52, ErrorMessage = "Week interval must be between 1 and 52." )] + public int WeekInterval { get; set; } = 1; + + public int DaysOfWeek { get; set; } + public int MonthsOfYear { get; set; } + public string MonthlyMode { get; set; } = "DayNumbers"; + public string DayNumbers { get; set; } = ""; + public int MonthlyWeekNumber { get; set; } + public int MonthlyDaysOfWeek { get; set; } + public bool HasExpiration { get; set; } + public DateOnly ExpirationDate { get; set; } = DateOnly.FromDateTime( DateTime.Today.AddYears( 1 ) ); + public TimeOnly ExpirationTime { get; set; } = new( 23, 59 ); + public string ExpirationTimeZoneId { get; set; } = TimeZoneInfo.Local.Id; + public bool HasRepeat { get; set; } + + [Range( 1, 1440, ErrorMessage = "Repeat interval must be between 1 and 1440 minutes." )] + public int RepeatIntervalMinutes { get; set; } = 15; + + [Range( 1, 1440, ErrorMessage = "Repeat duration must be between 1 and 1440 minutes." )] + public int RepeatDurationMinutes { get; set; } = 60; + + public static ScheduleFormModel FromDto( ScheduleDto dto ) { + string recType = dto.DailyRecurrence is not null ? "Daily" + : dto.WeeklyRecurrence is not null ? "Weekly" + : dto.MonthlyRecurrence is not null ? "Monthly" + : "None"; + + return new ScheduleFormModel { + Name = dto.Name, + StopTaskAfterMinutes = dto.StopTaskAfterMinutes, + StartDate = dto.StartDateTime?.Date ?? DateOnly.FromDateTime( DateTime.Today ), + StartTime = dto.StartDateTime?.Time ?? new TimeOnly( 8, 0 ), + TimeZoneId = dto.StartDateTime?.TimeZoneId ?? TimeZoneInfo.Local.Id, + RecurrenceType = recType, + DayInterval = dto.DailyRecurrence?.DayInterval ?? 1, + WeekInterval = dto.WeeklyRecurrence?.WeekInterval ?? 1, + DaysOfWeek = dto.WeeklyRecurrence?.DaysOfWeek ?? 0, + MonthsOfYear = dto.MonthlyRecurrence?.MonthsOfYear ?? 0, + MonthlyMode = dto.MonthlyRecurrence?.WeekNumber is not null ? "WeekAndDay" : "DayNumbers", + DayNumbers = dto.MonthlyRecurrence?.DayNumbers is not null + ? string.Join( ", ", dto.MonthlyRecurrence.DayNumbers ) + : "", + MonthlyWeekNumber = dto.MonthlyRecurrence?.WeekNumber ?? 0, + MonthlyDaysOfWeek = dto.MonthlyRecurrence?.DaysOfWeek ?? 0, + HasExpiration = dto.Expiration is not null, + ExpirationDate = dto.Expiration?.Date ?? DateOnly.FromDateTime( DateTime.Today.AddYears( 1 ) ), + ExpirationTime = dto.Expiration?.Time ?? new TimeOnly( 23, 59 ), + ExpirationTimeZoneId = dto.Expiration?.TimeZoneId ?? TimeZoneInfo.Local.Id, + HasRepeat = dto.RepeatOptions is not null, + RepeatIntervalMinutes = dto.RepeatOptions?.RepeatIntervalMinutes ?? 15, + RepeatDurationMinutes = dto.RepeatOptions?.RepeatDurationMinutes ?? 60, + }; + } + + public ScheduleUpdateRequest ToUpdateRequest( ) { + return new ScheduleUpdateRequest( + Name: Name, + StopTaskAfterMinutes: StopTaskAfterMinutes, + StartDateTime: new StartDateTimeDto( StartDate, StartTime, TimeZoneId ), + Expiration: HasExpiration + ? new ExpirationDateTimeDto( ExpirationDate, ExpirationTime, ExpirationTimeZoneId ) + : null, + DailyRecurrence: RecurrenceType == "Daily" ? new DailyRecurrenceDto( DayInterval ) : null, + WeeklyRecurrence: RecurrenceType == "Weekly" ? new WeeklyRecurrenceDto( WeekInterval, DaysOfWeek ) : null, + MonthlyRecurrence: RecurrenceType == "Monthly" + ? new MonthlyRecurrenceDto( + MonthlyMode == "DayNumbers" ? ParseDayNumbers( DayNumbers ) : null, + MonthsOfYear, + MonthlyMode == "WeekAndDay" ? MonthlyWeekNumber : null, + MonthlyMode == "WeekAndDay" ? MonthlyDaysOfWeek : null ) + : null, + RepeatOptions: HasRepeat + ? new RepeatOptionsDto( RepeatIntervalMinutes, RepeatDurationMinutes ) + : null ); + } + + public ScheduleCreateRequest ToPreviewRequest( ) { + return new ScheduleCreateRequest( + Name: Name, + StopTaskAfterMinutes: StopTaskAfterMinutes, + StartDateTime: new StartDateTimeDto( StartDate, StartTime, TimeZoneId ), + Expiration: HasExpiration + ? new ExpirationDateTimeDto( ExpirationDate, ExpirationTime, ExpirationTimeZoneId ) + : null, + DailyRecurrence: RecurrenceType == "Daily" ? new DailyRecurrenceDto( DayInterval ) : null, + WeeklyRecurrence: RecurrenceType == "Weekly" ? new WeeklyRecurrenceDto( WeekInterval, DaysOfWeek ) : null, + MonthlyRecurrence: RecurrenceType == "Monthly" + ? new MonthlyRecurrenceDto( + MonthlyMode == "DayNumbers" ? ParseDayNumbers( DayNumbers ) : null, + MonthsOfYear, + MonthlyMode == "WeekAndDay" ? MonthlyWeekNumber : null, + MonthlyMode == "WeekAndDay" ? MonthlyDaysOfWeek : null ) + : null, + RepeatOptions: HasRepeat + ? new RepeatOptionsDto( RepeatIntervalMinutes, RepeatDurationMinutes ) + : null ); + } + + private static int[]? ParseDayNumbers( string input ) { + if ( string.IsNullOrWhiteSpace( input ) ) return null; + return input.Split( ',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ) + .Select( int.Parse ) + .ToArray( ); + } + } +} diff --git a/src/Werkr.Server/Components/Pages/Schedules/Index.razor b/src/Werkr.Server/Components/Pages/Schedules/Index.razor new file mode 100644 index 0000000..cb28f3b --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Schedules/Index.razor @@ -0,0 +1,181 @@ +@page "/schedules" +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation + +Werkr - Schedules + +

Schedules

+ + +

Manage recurring execution schedules for tasks and workflows.

+ +
+ + New Schedule + + +
+ +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading && _schedules is null ) { + +} else if ( _schedules is not null && _schedules.Count > 0 ) { +
+ + + + + + + + + + + + + + @foreach ( ScheduleDto s in _schedules ) { + + + + + + + + + + } + +
NameRecurrenceStartExpiresStatusNext OccurrenceActions
@s.Name@DescribeRecurrence( s )@FormatStart( s.StartDateTime )@FormatExpiration( s.Expiration ) + @GetStatus( s ) + @GetNextOccurrence( s.Id ) + Edit + +
+
+} else if ( !_isLoading ) { + +} + + + +@code { + private List? _schedules; + private Dictionary _nextOccurrences = new( ); + private string? _errorMessage; + private bool _isLoading; + private ConfirmDialog _confirmDialog = default!; + private Guid _pendingDeleteId; + private string _deleteMessage = ""; + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Schedules" ) + ]; + + protected override async Task OnInitializedAsync( ) { + await LoadAsync( ); + } + + private async Task LoadAsync( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _schedules = await client.GetFromJsonAsync>( "/api/schedules" ); + + // Fetch next occurrence for each schedule concurrently + if ( _schedules is not null ) { + DateTime windowEnd = DateTime.UtcNow.AddDays( 60 ); + string windowEndParam = Uri.EscapeDataString( windowEnd.ToString( "o" ) ); + Task[] fetchTasks = _schedules.Select( s => + FetchNextOccurrenceAsync( client, s.Id, windowEndParam ) ).ToArray( ); + await Task.WhenAll( fetchTasks ); + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to load schedules: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private async Task FetchNextOccurrenceAsync( HttpClient client, Guid scheduleId, string windowEndParam ) { + try { + OccurrencePreview? preview = await client.GetFromJsonAsync( + $"/api/schedules/{scheduleId}/occurrences?windowEnd={windowEndParam}" ); + DateTime? next = preview?.Occurrences?.Where( o => o > DateTime.UtcNow ).OrderBy( o => o ).FirstOrDefault( ); + _nextOccurrences[scheduleId] = next == default ? null : next; + } catch { + _nextOccurrences[scheduleId] = null; + } + } + + private void PromptDelete( ScheduleDto schedule ) { + _pendingDeleteId = schedule.Id; + _deleteMessage = $"Are you sure you want to delete schedule '{schedule.Name}'? This cannot be undone."; + _confirmDialog.Show( ); + } + + private async Task DeleteConfirmedAsync( ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.DeleteAsync( $"/api/schedules/{_pendingDeleteId}" ); + if ( response.IsSuccessStatusCode ) { + await LoadAsync( ); + } else { + _errorMessage = $"Failed to delete schedule: HTTP {(int) response.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to delete schedule: {ex.Message}"; + } + } + + private static string DescribeRecurrence( ScheduleDto s ) { + if ( s.DailyRecurrence is not null ) return $"Daily (every {s.DailyRecurrence.DayInterval} day(s))"; + if ( s.WeeklyRecurrence is not null ) return $"Weekly (every {s.WeeklyRecurrence.WeekInterval} week(s))"; + if ( s.MonthlyRecurrence is not null ) return "Monthly"; + return "One-time"; + } + + private static string FormatStart( StartDateTimeDto? start ) { + return start is null ? "—" : $"{start.Date:yyyy-MM-dd} {start.Time:HH:mm} ({start.TimeZoneId})"; + } + + private static string FormatExpiration( ExpirationDateTimeDto? exp ) { + return exp is null ? "None" : $"{exp.Date:yyyy-MM-dd} {exp.Time:HH:mm}"; + } + + private static string GetStatus( ScheduleDto s ) { + if ( s.Expiration is not null ) { + DateTime expDt = s.Expiration.Date.ToDateTime( s.Expiration.Time ); + if ( expDt < DateTime.Now ) return "Expired"; + } + return "Active"; + } + + private static string GetStatusBadgeClass( ScheduleDto s ) => + GetStatus( s ) == "Active" ? "bg-success" : "bg-secondary"; + + private string GetNextOccurrence( Guid scheduleId ) { + if ( _nextOccurrences.TryGetValue( scheduleId, out DateTime? next ) && next.HasValue ) { + return next.Value.ToLocalTime( ).ToString( "g" ); + } + return "—"; + } + + private sealed record OccurrencePreview( Guid ScheduleId, DateTime WindowEnd, List? Occurrences ); +} diff --git a/src/Werkr.Server/Components/Pages/Settings.razor b/src/Werkr.Server/Components/Pages/Settings.razor new file mode 100644 index 0000000..fc934ee --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Settings.razor @@ -0,0 +1,349 @@ +@page "/settings" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Identity +@using Microsoft.Extensions.Options +@using System.Runtime.InteropServices +@using Werkr.Common.Models +@attribute [Authorize( Roles = "Admin" )] +@rendermode InteractiveServer +@inject IHttpClientFactory HttpClientFactory +@inject IConfiguration Configuration +@inject IOptions IdentityConfig + +Werkr - Settings + +
+

Settings

+ + +

Read-only configuration and diagnostics for this Werkr instance.

+ +
+ +
+ + @if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { +
@_errorMessage
+ } + +
+
+
Server Configuration
+
+
Server Name
+
@_serverName
+ +
Server URL
+
@_serverUrl
+ +
Default Command Timeout
+
@_defaultCommandTimeoutMinutes minutes
+ +
RSA Key Size
+
@_defaultKeySize bits
+ +
Allow Registration
+
+ + @(_allowRegistration ? "Yes" : "No") + +
+
+
+
+ +
+
+
Identity Configuration
+
+
Min Password Length
+
@IdentityConfig.Value.Password.RequiredLength
+ +
Require Digit
+
@FormatBoolean( IdentityConfig.Value.Password.RequireDigit )
+ +
Require Lowercase
+
@FormatBoolean( IdentityConfig.Value.Password.RequireLowercase )
+ +
Require Uppercase
+
@FormatBoolean( IdentityConfig.Value.Password.RequireUppercase )
+ +
Require Special Character
+
@FormatBoolean( IdentityConfig.Value.Password.RequireNonAlphanumeric )
+ +
Min Unique Characters
+
@IdentityConfig.Value.Password.RequiredUniqueChars
+ +
Lockout Duration
+
@IdentityConfig.Value.Lockout.DefaultLockoutTimeSpan.TotalMinutes minutes
+ +
Max Failed Attempts
+
@IdentityConfig.Value.Lockout.MaxFailedAccessAttempts
+ +
Require Unique Email
+
@FormatBoolean( IdentityConfig.Value.User.RequireUniqueEmail )
+
+
+
+ +
+
+
Database Health
+ @if ( _databaseHealth.Count == 0 ) { +

No database diagnostics available.

+ } else { +
+ + + + + + + + + + + + @foreach ( DatabaseHealthDto item in _databaseHealth ) { + + + + + + + + @if ( item.PendingMigrationCount > 0 ) { + + + + } + } + +
ContextProviderConnectedAppliedPending
@item.ContextName@item.ProviderName + + @(item.IsConnected ? "Yes" : "No") + + @item.AppliedMigrationCount@item.PendingMigrationCount
+ Pending: @string.Join( ", ", item.PendingMigrations ) +
+
+ } +
+
+ +
+
+
Agent Configuration Summary
+ @if ( _agentHealth.Count == 0 ) { +

No agent health data available.

+ } else { +
+ + + + + + + + + + + @foreach ( AgentHealthDto item in _agentHealth ) { + + + + + + + } + +
Agent NamePowerShellSystemShellStatus
@item.ConnectionName@FormatNullableBoolean( item.PowerShellAvailable )@FormatNullableBoolean( item.SystemShellAvailable )@item.Status
+
+ } +
+
+ +
+
+
Infrastructure
+
+
Aspire Service Discovery
+
@( _serviceDiscoveryKeys.Count > 0 ? string.Join( ", ", _serviceDiscoveryKeys ) : "No services:* keys found" )
+ +
OTLP Endpoint
+
@(string.IsNullOrWhiteSpace( _otlpEndpoint ) ? "Not configured" : _otlpEndpoint)
+ +
.NET Runtime
+
@Environment.Version
+ +
OS
+
@RuntimeInformation.OSDescription
+ +
Architecture
+
@RuntimeInformation.ProcessArchitecture
+
+
+
+ +
+
+
Notify Agents of Server URL Change
+

+ If this Server's gRPC URL has changed, notify all connected Agents so they update + their stored endpoint and reconnect automatically. +

+
+ + +
+ + + @if ( _notifyResult is not null ) { +
+ @_notifyResult.Notified agents notified successfully. + @if ( _notifyResult.Failed > 0 ) { +
+ @_notifyResult.Failed agents failed: + @string.Join( ", ", _notifyResult.FailedAgents ) + } +
+ } +
+
+
+ +@code { + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Settings" ) + ]; + + private List _databaseHealth = []; + private List _agentHealth = []; + private List _serviceDiscoveryKeys = []; + private string? _errorMessage; + private bool _isLoading; + + private string _serverName = "Werkr Server"; + private string _serverUrl = "Not configured"; + private int _defaultCommandTimeoutMinutes = 30; + private int _defaultKeySize = 4096; + private bool _allowRegistration = true; + private string _otlpEndpoint = string.Empty; + + // URL notification state + private string _notifyNewUrl = string.Empty; + private bool _isNotifying; + private NotifyUrlChangeResponse? _notifyResult; + + protected override async Task OnInitializedAsync( ) { + LoadConfiguration( ); + await LoadAsync( ); + } + + private void LoadConfiguration( ) { + _serverName = Configuration["Server:Name"] ?? "Werkr Server"; + _serverUrl = Configuration["Werkr:ServerUrl"] ?? "Not configured"; + + _defaultCommandTimeoutMinutes = int.TryParse( Configuration["Werkr:DefaultCommandTimeoutMinutes"], out int timeoutMinutes ) + ? timeoutMinutes + : 30; + + _defaultKeySize = int.TryParse( Configuration["Werkr:DefaultKeySize"], out int keySize ) + ? keySize + : 4096; + + _allowRegistration = bool.TryParse( Configuration["Server:AllowRegistration"], out bool allowRegistration ) + ? allowRegistration + : true; + + _serviceDiscoveryKeys = [.. Configuration.AsEnumerable( ) + .Where( kvp => !string.IsNullOrWhiteSpace( kvp.Key ) && kvp.Key.StartsWith( "services:", StringComparison.OrdinalIgnoreCase ) ) + .Select( kvp => kvp.Key ) + .Distinct( ) + .OrderBy( key => key )]; + + _otlpEndpoint = Environment.GetEnvironmentVariable( "OTEL_EXPORTER_OTLP_ENDPOINT" ) ?? string.Empty; + } + + private async Task LoadAsync( ) { + _isLoading = true; + _errorMessage = null; + + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + + Task?> databaseTask = client.GetFromJsonAsync>( "/api/diagnostics/health" ); + Task?> healthTask = client.GetFromJsonAsync>( "/api/agents/health" ); + + await Task.WhenAll( databaseTask, healthTask ); + + _databaseHealth = await databaseTask ?? []; + _agentHealth = await healthTask ?? []; + } catch ( Exception ex ) { + _errorMessage = $"Failed to load diagnostics: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private static string FormatBoolean( bool value ) { + return value ? "Yes" : "No"; + } + + private static string FormatNullableBoolean( bool? value ) { + return value switch { + true => "Yes", + false => "No", + null => "Unknown" + }; + } + + private static string GetStatusBadgeClass( string status ) { + return status switch { + "Connected" => "bg-success", + "Disconnected" => "bg-warning text-dark", + "Revoked" => "bg-secondary", + "Unreachable" => "bg-danger", + _ => "bg-info" + }; + } + + private async Task NotifyUrlChangeAsync( ) { + if (string.IsNullOrWhiteSpace( _notifyNewUrl )) { + _errorMessage = "Please enter the new server URL."; + return; + } + + _isNotifying = true; + _notifyResult = null; + _errorMessage = null; + + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + NotifyUrlChangeRequest request = new( _notifyNewUrl.Trim( ) ); + HttpResponseMessage response = await client.PostAsJsonAsync( "/api/settings/notify-url-change", request ); + + if (response.IsSuccessStatusCode) { + _notifyResult = await response.Content.ReadFromJsonAsync( ); + } else { + _errorMessage = $"Failed to send URL notification: {response.StatusCode}"; + } + } catch (Exception ex) { + _errorMessage = $"Failed to send URL notification: {ex.Message}"; + } finally { + _isNotifying = false; + } + } +} diff --git a/src/Werkr.Server/Components/Pages/Tasks/Create.razor b/src/Werkr.Server/Components/Pages/Tasks/Create.razor new file mode 100644 index 0000000..e261f8b --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Tasks/Create.razor @@ -0,0 +1,220 @@ +@page "/tasks/create" +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation + +Werkr - New Task + +

Create Task

+ + +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + + + + + +
+
+
Basic Information
+ +
+ + +
+ +
+ + +
+ +
+
+ + + + + + + + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+ + @if ( _model.ActionType == "Action" ) { +
+
+
Action Parameters
+ +
+
+ } + +
+
+
Targeting & Scheduling
+ +
+ + +
+ +
+
+ + + + @foreach ( ScheduleDto s in _schedules ) { + + } + +
+
+ + +
+
+ +
+
+ + + + @foreach ( WorkflowDto w in _workflows ) { + + } + +
+
+ +
+ +
+
+
+ +
+ + Cancel +
+
+ +@code { + private readonly TaskFormModel _model = new( ); + private string? _errorMessage; + private bool _isSaving; + private List _schedules = []; + private List _workflows = []; + private string[] _targetTags = []; + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Tasks", "/tasks" ), + new( "Create" ) + ]; + + protected override async Task OnInitializedAsync( ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _schedules = await client.GetFromJsonAsync>( "/api/schedules" ) ?? []; + _workflows = await client.GetFromJsonAsync>( "/api/workflows" ) ?? []; + } catch { + // Non-critical — dropdown will be empty + } + } + + private async Task SubmitAsync( ) { + _isSaving = true; + _errorMessage = null; + _model.TargetTags = string.Join( ",", _targetTags ); + try { + TaskCreateRequest request = _model.ToCreateRequest( ); + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PostAsJsonAsync( "/api/tasks", request ); + if ( response.IsSuccessStatusCode ) { + Navigation.NavigateTo( "/tasks" ); + } else { + string body = await response.Content.ReadAsStringAsync( ); + _errorMessage = $"Failed to create task: {body}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to create task: {ex.Message}"; + } finally { + _isSaving = false; + } + } + + private void OnTargetTagsChanged( string[] tags ) { + _targetTags = tags; + _model.TargetTags = string.Join( ",", tags ); + } + + private sealed class TaskFormModel { + public string Name { get; set; } = ""; + public string? Description { get; set; } + public string ActionType { get; set; } = "ShellCommand"; + public string Content { get; set; } = ""; + public string? Arguments { get; set; } + public string TargetTags { get; set; } = ""; + public bool Enabled { get; set; } = true; + public long? TimeoutMinutes { get; set; } + public string? SuccessCriteria { get; set; } + public string? ScheduleId { get; set; } + public string? WorkflowId { get; set; } + public string ActionSubType { get; set; } = ""; + public string? ActionParameters { get; set; } + + public TaskCreateRequest ToCreateRequest( ) { + string[]? args = string.IsNullOrWhiteSpace( Arguments ) + ? null + : Arguments.Split( '\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ); + + string[] tags = string.IsNullOrWhiteSpace( TargetTags ) + ? [] + : TargetTags.Split( ',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ); + + Guid? scheduleId = Guid.TryParse( ScheduleId, out Guid sid ) ? sid : null; + long? workflowId = long.TryParse( WorkflowId, out long wid ) ? wid : null; + + return new TaskCreateRequest( + Name: Name, + Description: Description, + ActionType: ActionType, + Content: Content, + Arguments: args, + TargetTags: tags, + Enabled: Enabled, + TimeoutMinutes: TimeoutMinutes, + SuccessCriteria: SuccessCriteria, + ScheduleId: scheduleId, + WorkflowId: workflowId, + ActionSubType: string.IsNullOrWhiteSpace( ActionSubType ) ? null : ActionSubType, + ActionParameters: string.IsNullOrWhiteSpace( ActionParameters ) ? null : ActionParameters ); + } + } +} diff --git a/src/Werkr.Server/Components/Pages/Tasks/Edit.razor b/src/Werkr.Server/Components/Pages/Tasks/Edit.razor new file mode 100644 index 0000000..57798e3 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Tasks/Edit.razor @@ -0,0 +1,317 @@ +@page "/tasks/{Id:long}" +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation + +Werkr - Edit Task + +

Edit Task

+ + +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading ) { + +} else if ( _model is not null ) { + + + + +
+
+
Basic Information
+ +
+ + +
+ +
+ + +
+ +
+
+ + + + + + + + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+ + @if ( _model.ActionType == "Action" ) { +
+
+
Action Parameters
+ +
+
+ } + +
+
+
Targeting & Scheduling
+ +
+ + +
+ +
+
+ + + + @foreach ( ScheduleDto s in _schedules ) { + + } + +
+
+ + +
+
+ +
+
+ + + + @foreach ( WorkflowDto w in _workflows ) { + + } + +
+
+ + @if ( _syncIntervalMinutes.HasValue ) { +
+ +

@_syncIntervalMinutes min

+
+ } + +
+ +
+
+
+ +
+ + Cancel +
+
+ +
+ +
+

Job History

+ + View All Logs + +
+ @if ( _jobs is not null && _jobs.Count > 0 ) { +
+ + + + + + + + + + + + + @foreach ( JobListDto j in _jobs ) { + + + + + + + + + } + +
IDStartedRuntimeResultError Category
@j.Id.ToString()[..8]…@j.StartTime.ToString( "g" )@j.RuntimeSeconds.ToString( "F1" )s + + @( j.Success ? "Success" : "Failed" ) + + @j.ErrorCategoryView Log
+
+ } else { +

No jobs recorded for this task.

+ } +} + +@code { + [Parameter] public long Id { get; set; } + + private TaskFormModel? _model; + private List? _jobs; + private List _schedules = []; + private List _workflows = []; + private int? _syncIntervalMinutes; + private string? _errorMessage; + private bool _isLoading; + private bool _isSaving; + private string[] _targetTags = []; + + private List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Tasks", "/tasks" ), + new( "Edit" ) + ]; + + protected override async Task OnInitializedAsync( ) { + _isLoading = true; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + + // Load schedules for dropdown + _schedules = await client.GetFromJsonAsync>( "/api/schedules" ) ?? []; + _workflows = await client.GetFromJsonAsync>( "/api/workflows" ) ?? []; + + TaskDto? dto = await client.GetFromJsonAsync( $"/api/tasks/{Id}" ); + if ( dto is null ) { + _errorMessage = "Task not found."; + return; + } + _breadcrumbs = [new( "Task List", "/" ), new( "Tasks", "/tasks" ), new( dto.Name )]; + _model = TaskFormModel.FromDto( dto ); + _targetTags = dto.TargetTags; + _syncIntervalMinutes = dto.SyncIntervalMinutes; + + _jobs = await client.GetFromJsonAsync>( $"/api/tasks/{Id}/jobs?limit=25" ); + } catch ( Exception ex ) { + _errorMessage = $"Failed to load task: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private async Task SubmitAsync( ) { + if ( _model is null ) return; + _isSaving = true; + _errorMessage = null; + _model.TargetTags = string.Join( ",", _targetTags ); + try { + TaskUpdateRequest request = _model.ToUpdateRequest( ); + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PutAsJsonAsync( $"/api/tasks/{Id}", request ); + if ( response.IsSuccessStatusCode ) { + Navigation.NavigateTo( "/tasks" ); + } else { + string body = await response.Content.ReadAsStringAsync( ); + _errorMessage = $"Failed to update task: {body}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to update task: {ex.Message}"; + } finally { + _isSaving = false; + } + } + + private void OnTargetTagsChanged( string[] tags ) { + _targetTags = tags; + if (_model is not null) { + _model.TargetTags = string.Join( ",", tags ); + } + } + + private sealed class TaskFormModel { + public string Name { get; set; } = ""; + public string? Description { get; set; } + public string ActionType { get; set; } = "ShellCommand"; + public string Content { get; set; } = ""; + public string? Arguments { get; set; } + public string TargetTags { get; set; } = ""; + public bool Enabled { get; set; } = true; + public long? TimeoutMinutes { get; set; } + public string? SuccessCriteria { get; set; } + public string? ScheduleId { get; set; } + public string? WorkflowId { get; set; } + public string ActionSubType { get; set; } = ""; + public string? ActionParameters { get; set; } + + public static TaskFormModel FromDto( TaskDto dto ) { + return new TaskFormModel { + Name = dto.Name, + Description = dto.Description, + ActionType = dto.ActionType, + Content = dto.Content, + Arguments = dto.Arguments is not null ? string.Join( "\n", dto.Arguments ) : null, + TargetTags = string.Join( ", ", dto.TargetTags ), + Enabled = dto.Enabled, + TimeoutMinutes = dto.TimeoutMinutes, + SuccessCriteria = dto.SuccessCriteria, + ScheduleId = dto.ScheduleId?.ToString( ), + WorkflowId = dto.WorkflowId?.ToString( ), + ActionSubType = dto.ActionSubType ?? "", + ActionParameters = dto.ActionParameters, + }; + } + + public TaskUpdateRequest ToUpdateRequest( ) { + string[]? args = string.IsNullOrWhiteSpace( Arguments ) + ? null + : Arguments.Split( '\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ); + + string[] tags = string.IsNullOrWhiteSpace( TargetTags ) + ? [] + : TargetTags.Split( ',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ); + + Guid? scheduleId = Guid.TryParse( ScheduleId, out Guid sid ) ? sid : null; + long? workflowId = long.TryParse( WorkflowId, out long wid ) ? wid : null; + + return new TaskUpdateRequest( + Name: Name, + Description: Description, + ActionType: ActionType, + Content: Content, + Arguments: args, + TargetTags: tags, + Enabled: Enabled, + TimeoutMinutes: TimeoutMinutes, + SuccessCriteria: SuccessCriteria, + ScheduleId: scheduleId, + WorkflowId: workflowId, + ActionSubType: string.IsNullOrWhiteSpace( ActionSubType ) ? null : ActionSubType, + ActionParameters: string.IsNullOrWhiteSpace( ActionParameters ) ? null : ActionParameters ); + } + } +} diff --git a/src/Werkr.Server/Components/Pages/Tasks/Index.razor b/src/Werkr.Server/Components/Pages/Tasks/Index.razor new file mode 100644 index 0000000..4df5d78 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Tasks/Index.razor @@ -0,0 +1,190 @@ +@page "/tasks" +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation + +Werkr - Tasks + +

Tasks

+ + +

Manage automation tasks — the units of work executed on agents.

+ +
+ + New Task + + +
+ +
+ + + + +
+ +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading && _tasks is null ) { + +} else if ( _tasks is not null && FilteredTasks.Count > 0 ) { +
+ + + + + + + + + + + + + @foreach ( TaskDto t in FilteredTasks ) { + + + + + + + + + } + +
IDNameActionTagsEnabledActions
@t.Id@t.Name@t.ActionType + @foreach ( string tag in t.TargetTags ) { + @tag + } + + + @( t.Enabled ? "Yes" : "No" ) + + + Edit + Logs + + +
+
+} else if ( !_isLoading ) { + +} + + + +@code { + private List? _tasks; + private string? _errorMessage; + private bool _isLoading; + private bool _isRunning; + private string _filterText = ""; + private string _filterActionType = ""; + private string _filterEnabled = ""; + private string _filterTag = ""; + private ConfirmDialog _confirmDialog = default!; + private long _pendingDeleteId; + private string _deleteMessage = ""; + + private List FilteredTasks => ( _tasks ?? [] ) + .Where( t => + ( string.IsNullOrWhiteSpace( _filterText ) + || t.Name.Contains( _filterText, StringComparison.OrdinalIgnoreCase ) ) + && ( string.IsNullOrWhiteSpace( _filterActionType ) + || t.ActionType.Equals( _filterActionType, StringComparison.OrdinalIgnoreCase ) ) + && ( _filterEnabled switch { + "enabled" => t.Enabled, + "disabled" => !t.Enabled, + _ => true + } ) + && ( string.IsNullOrWhiteSpace( _filterTag ) + || t.TargetTags.Any( tag => tag.Contains( _filterTag, StringComparison.OrdinalIgnoreCase ) ) ) ) + .ToList( ); + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Tasks" ) + ]; + + protected override async Task OnInitializedAsync( ) { + await LoadAsync( ); + } + + private async Task LoadAsync( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _tasks = await client.GetFromJsonAsync>( "/api/tasks" ); + } catch ( Exception ex ) { + _errorMessage = $"Failed to load tasks: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private void PromptDelete( TaskDto task ) { + _pendingDeleteId = task.Id; + _deleteMessage = $"Are you sure you want to delete task '{task.Name}'? All associated jobs will be lost."; + _confirmDialog.Show( ); + } + + private async Task DeleteConfirmedAsync( ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.DeleteAsync( $"/api/tasks/{_pendingDeleteId}" ); + if ( response.IsSuccessStatusCode ) { + await LoadAsync( ); + } else { + _errorMessage = $"Failed to delete task: HTTP {(int) response.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to delete task: {ex.Message}"; + } + } + + private async Task RunTask( long taskId ) { + _isRunning = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PostAsJsonAsync( $"/api/tasks/{taskId}/run", new TaskRunRequest( null ) ); + if ( !response.IsSuccessStatusCode ) { + string body = await response.Content.ReadAsStringAsync( ); + _errorMessage = $"Run failed: {body}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Run failed: {ex.Message}"; + } finally { + _isRunning = false; + } + } +} diff --git a/src/Werkr.Server/Components/Pages/Workflows/Create.razor b/src/Werkr.Server/Components/Pages/Workflows/Create.razor new file mode 100644 index 0000000..2bb6703 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Workflows/Create.razor @@ -0,0 +1,100 @@ +@page "/workflows/create" +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation + +Werkr - New Workflow + +

Create Workflow

+ + +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + + + + + +
+
+
Workflow Details
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ +
+ + Cancel +
+
+ +@code { + private readonly WorkflowFormModel _model = new( ); + private string? _errorMessage; + private bool _isSaving; + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Workflows", "/workflows" ), + new( "Create" ) + ]; + + private async Task SubmitAsync( ) { + _isSaving = true; + _errorMessage = null; + try { + Guid? scheduleId = Guid.TryParse( _model.ScheduleId, out Guid sid ) ? sid : null; + WorkflowCreateRequest request = new( + Name: _model.Name, + Description: _model.Description, + Enabled: _model.Enabled, + ScheduleId: scheduleId ); + + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PostAsJsonAsync( "/api/workflows", request ); + if ( response.IsSuccessStatusCode ) { + WorkflowDto? created = await response.Content.ReadFromJsonAsync( ); + Navigation.NavigateTo( $"/workflows/{created?.Id ?? 0}" ); + } else { + string body = await response.Content.ReadAsStringAsync( ); + _errorMessage = $"Failed to create workflow: {body}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to create workflow: {ex.Message}"; + } finally { + _isSaving = false; + } + } + + private sealed class WorkflowFormModel { + public string Name { get; set; } = ""; + public string? Description { get; set; } + public string? ScheduleId { get; set; } + public bool Enabled { get; set; } = true; + } +} diff --git a/src/Werkr.Server/Components/Pages/Workflows/Dag.razor b/src/Werkr.Server/Components/Pages/Workflows/Dag.razor new file mode 100644 index 0000000..389b9a1 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Workflows/Dag.razor @@ -0,0 +1,107 @@ +@page "/workflows/{Id:long}/dag" +@rendermode InteractiveServer +@attribute [Authorize] +@inject IHttpClientFactory HttpClientFactory + +Werkr - Workflow DAG + +

Workflow DAG

+ + +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading ) { + +} else if ( _workflow is not null ) { +
+

@_workflow.Name

+ + Back to Workflow + +
+ + @if ( _workflow.Steps.Count == 0 ) { + + } else { +
+
+ +
+
+ + @if ( _selectedStep is not null ) { +
+
+
Step Details
+ +
+
+
+
Step
+
Step #@_selectedStep.Id (Order: @_selectedStep.Order)
+ +
Control
+
@_selectedStep.ControlStatement
+ +
Task
+
+ Task #@_selectedStep.TaskId +
+ + @if ( _selectedStep.Dependencies is { Count: > 0 } ) { +
Depends On
+
+ @foreach ( StepDependencyDto dep in _selectedStep.Dependencies ) { + Step @dep.DependsOnStepId + } +
+ } +
+
+
+ } + } +} + +@code { + [Parameter] + public long Id { get; set; } + + private WorkflowDto? _workflow; + private WorkflowStepDto? _selectedStep; + private string? _errorMessage; + private bool _isLoading; + + private List _breadcrumbs = []; + + protected override async Task OnInitializedAsync( ) { + _breadcrumbs = [ + new( "Task List", "/" ), + new( "Workflows", "/workflows" ), + new( $"Workflow #{Id}", $"/workflows/{Id}" ), + new( "DAG" ) + ]; + await LoadAsync( ); + } + + private async Task LoadAsync( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _workflow = await client.GetFromJsonAsync( $"/api/workflows/{Id}" ); + } catch ( Exception ex ) { + _errorMessage = $"Failed to load workflow: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private void HandleStepSelected( WorkflowStepDto step ) { + _selectedStep = step; + } +} diff --git a/src/Werkr.Server/Components/Pages/Workflows/Edit.razor b/src/Werkr.Server/Components/Pages/Workflows/Edit.razor new file mode 100644 index 0000000..841f95c --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Workflows/Edit.razor @@ -0,0 +1,446 @@ +@page "/workflows/{Id:long}" +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation + +Werkr - Edit Workflow + +

Edit Workflow

+ + +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( !string.IsNullOrWhiteSpace( _successMessage ) ) { + +} + +@if ( _isLoading ) { + +} else if ( _workflow is not null ) { + @* ── Workflow Metadata ── *@ +
+
+
Workflow Details
+ + +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ +
+ +
+ + +
+
+
+ + @* ── DAG Visualization ── *@ + @if ( _workflow.Steps.Count > 0 ) { +
+
+
Workflow DAG
+ +
+
+ } + + @* ── DAG Validation ── *@ +
+
+
DAG Validation
+ + @if ( _dagResult is not null ) { +
+ @if ( _dagResult.IsValid ) { + Valid + } else { + Invalid +
    + @foreach ( string err in _dagResult.Errors ) { +
  • @err
  • + } +
+ } +
+ } +
+
+ + @* ── Steps ── *@ +
+
+
Steps (@_workflow.Steps.Count)
+ + @if ( _workflow.Steps.Count > 0 ) { +
+ + + + + + + + + + + + + + + + @foreach ( WorkflowStepDto step in _workflow.Steps.OrderBy( s => s.Order ) ) { + + + + + + + + + + + + } + +
OrderStep IDTaskControlConditionMax IterDep. ModeDependenciesActions
@step.Order@step.Id@TaskName( step.TaskId )@step.ControlStatement@( step.ConditionExpression ?? "—" )@step.MaxIterations@step.DependencyMode + @if ( step.Dependencies.Count > 0 ) { + @string.Join( ", ", step.Dependencies.Select( d => d.DependsOnStepId ) ) + } else { + None + } + + +
+
+ } else { +

No steps yet. Add a step below.

+ } + +
Add Step
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ @if ( IsConditionalControl( _newStep.ControlStatement ) ) { +
+
+ + +
+
+ + +
+
+ } + + @if ( _workflow.Steps.Count > 0 ) { +
Add Dependency
+
+
+ + +
+
+ + +
+
+ +
+
+ } +
+
+ + @* ── Recent Runs ── *@ +
+
+
Recent Runs
+ View All Runs + @if ( _runs is not null && _runs.Count > 0 ) { +
+ + + + + + + + + + + @foreach ( WorkflowRunDto run in _runs.Take( 10 ) ) { + + + + + + + } + +
Run IDStartedEndedStatus
@run.Id.ToString()[..8]…@run.StartTime.ToString( "g" )@( run.EndTime?.ToString( "g" ) ?? "—" ) + @run.Status +
+
+ } else { +

No runs recorded yet.

+ } +
+
+} + +@code { + [Parameter] public long Id { get; set; } + + private WorkflowDto? _workflow; + private WorkflowFormModel? _model; + private List? _runs; + private List? _tasks; + private DagValidationResult? _dagResult; + + private string? _errorMessage; + private string? _successMessage; + private bool _isLoading; + private bool _isSaving; + private bool _isValidating; + + private readonly NewStepModel _newStep = new( ); + private long _depStepId; + private long _depDependsOnStepId; + + private List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Workflows", "/workflows" ), + new( "Edit" ) + ]; + + protected override async Task OnInitializedAsync( ) { + await LoadAsync( ); + } + + private async Task LoadAsync( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _workflow = await client.GetFromJsonAsync( $"/api/workflows/{Id}" ); + if ( _workflow is null ) { + _errorMessage = "Workflow not found."; + return; + } + _breadcrumbs = [new( "Task List", "/" ), new( "Workflows", "/workflows" ), new( _workflow.Name )]; + _model = new WorkflowFormModel { + Name = _workflow.Name, + Description = _workflow.Description, + ScheduleId = _workflow.ScheduleId?.ToString( ), + Enabled = _workflow.Enabled, + }; + _runs = await client.GetFromJsonAsync>( $"/api/workflows/{Id}/runs" ); + _tasks = await client.GetFromJsonAsync>( "/api/tasks" ); + } catch ( Exception ex ) { + _errorMessage = $"Failed to load workflow: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private async Task SaveWorkflowAsync( ) { + if ( _model is null ) return; + _isSaving = true; + _errorMessage = null; + _successMessage = null; + try { + Guid? scheduleId = Guid.TryParse( _model.ScheduleId, out Guid sid ) ? sid : null; + WorkflowUpdateRequest request = new( + Name: _model.Name, + Description: _model.Description, + Enabled: _model.Enabled, + ScheduleId: scheduleId ); + + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PutAsJsonAsync( $"/api/workflows/{Id}", request ); + if ( response.IsSuccessStatusCode ) { + _successMessage = "Workflow saved."; + await LoadAsync( ); + } else { + string body = await response.Content.ReadAsStringAsync( ); + _errorMessage = $"Failed to save: {body}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to save: {ex.Message}"; + } finally { + _isSaving = false; + } + } + + private async Task ValidateDagAsync( ) { + _isValidating = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PostAsync( $"/api/workflows/{Id}/validate", null ); + _dagResult = await response.Content.ReadFromJsonAsync( ); + } catch ( Exception ex ) { + _errorMessage = $"Validation failed: {ex.Message}"; + } finally { + _isValidating = false; + } + } + + private async Task AddStepAsync( ) { + _errorMessage = null; + try { + int autoOrder = _workflow?.Steps.Count > 0 + ? _workflow.Steps.Max( s => s.Order ) + 1 + : 1; + + WorkflowStepCreateRequest request = new( + TaskId: _newStep.TaskId, + Order: autoOrder, + ControlStatement: _newStep.ControlStatement, + ConditionExpression: string.IsNullOrWhiteSpace( _newStep.ConditionExpression ) ? null : _newStep.ConditionExpression, + MaxIterations: _newStep.MaxIterations, + DependencyMode: _newStep.DependencyMode ); + + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PostAsJsonAsync( + $"/api/workflows/{Id}/steps", request ); + if ( response.IsSuccessStatusCode ) { + await LoadAsync( ); + } else { + string body = await response.Content.ReadAsStringAsync( ); + _errorMessage = $"Failed to add step: {body}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to add step: {ex.Message}"; + } + } + + private async Task DeleteStepAsync( long stepId ) { + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.DeleteAsync( + $"/api/workflows/{Id}/steps/{stepId}" ); + if ( response.IsSuccessStatusCode ) { + await LoadAsync( ); + } else { + _errorMessage = $"Failed to remove step: HTTP {(int) response.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to remove step: {ex.Message}"; + } + } + + private async Task AddDependencyAsync( ) { + _errorMessage = null; + try { + StepDependencyRequest request = new( _depDependsOnStepId ); + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PostAsJsonAsync( + $"/api/workflows/{Id}/steps/{_depStepId}/dependencies", request ); + if ( response.IsSuccessStatusCode ) { + await LoadAsync( ); + } else { + string body = await response.Content.ReadAsStringAsync( ); + _errorMessage = $"Failed to add dependency: {body}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to add dependency: {ex.Message}"; + } + } + + private static string RunStatusBadge( string status ) => status switch { + "Completed" => "bg-success", + "Running" => "bg-primary", + "Failed" => "bg-danger", + "Cancelled" => "bg-warning text-dark", + _ => "bg-secondary" + }; + + private string TaskName( long taskId ) { + TaskDto? t = _tasks?.FirstOrDefault( x => x.Id == taskId ); + return t is not null ? t.Name : $"Task #{taskId}"; + } + + private static bool IsConditionalControl( string control ) => + control is "ConditionalIf" or "ConditionalElseIf" or "ConditionalWhile" or "ConditionalDo"; + + private sealed class WorkflowFormModel { + public string Name { get; set; } = ""; + public string? Description { get; set; } + public string? ScheduleId { get; set; } + public bool Enabled { get; set; } = true; + } + + private sealed class NewStepModel { + public long TaskId { get; set; } + public string ControlStatement { get; set; } = "Sequential"; + public string DependencyMode { get; set; } = "All"; + public string? ConditionExpression { get; set; } + public int MaxIterations { get; set; } = 100; + } +} diff --git a/src/Werkr.Server/Components/Pages/Workflows/Index.razor b/src/Werkr.Server/Components/Pages/Workflows/Index.razor new file mode 100644 index 0000000..71644e7 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Workflows/Index.razor @@ -0,0 +1,148 @@ +@page "/workflows" +@rendermode InteractiveServer +@attribute [Authorize( Roles = "Admin,Operator" )] +@inject IHttpClientFactory HttpClientFactory +@inject NavigationManager Navigation + +Werkr - Workflows + +

Workflows

+ + +

Manage multi-step orchestrated workflows with DAG-based execution.

+ +
+ + New Workflow + + +
+ +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading && _workflows is null ) { + +} else if ( _workflows is not null && _workflows.Count > 0 ) { +
+ + + + + + + + + + + + + @foreach ( WorkflowDto w in _workflows ) { + + + + + + + + + } + +
IDNameStepsEnabledScheduleActions
@w.Id@w.Name@w.Steps.Count + + @( w.Enabled ? "Yes" : "No" ) + + @( w.ScheduleId?.ToString()[..8] ?? "—" ) + Edit + + Runs + +
+
+} else if ( !_isLoading ) { + +} + + + +@code { + private List? _workflows; + private string? _errorMessage; + private bool _isLoading; + private bool _isRunning; + private ConfirmDialog _confirmDialog = default!; + private long _pendingDeleteId; + private string _deleteMessage = ""; + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Workflows" ) + ]; + + protected override async Task OnInitializedAsync( ) { + await LoadAsync( ); + } + + private async Task LoadAsync( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _workflows = await client.GetFromJsonAsync>( "/api/workflows" ); + } catch ( Exception ex ) { + _errorMessage = $"Failed to load workflows: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private void PromptDelete( WorkflowDto wf ) { + _pendingDeleteId = wf.Id; + _deleteMessage = $"Are you sure you want to delete workflow '{wf.Name}'? All steps and runs will be lost."; + _confirmDialog.Show( ); + } + + private async Task DeleteConfirmedAsync( ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.DeleteAsync( $"/api/workflows/{_pendingDeleteId}" ); + if ( response.IsSuccessStatusCode ) { + await LoadAsync( ); + } else { + _errorMessage = $"Failed to delete workflow: HTTP {(int) response.StatusCode}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to delete workflow: {ex.Message}"; + } + } + + private async Task RunWorkflow( long workflowId ) { + _isRunning = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + HttpResponseMessage response = await client.PostAsync( + $"/api/workflows/{workflowId}/run", null ); + if ( !response.IsSuccessStatusCode ) { + string body = await response.Content.ReadAsStringAsync( ); + _errorMessage = $"Run failed: {body}"; + } + } catch ( Exception ex ) { + _errorMessage = $"Run failed: {ex.Message}"; + } finally { + _isRunning = false; + } + } +} diff --git a/src/Werkr.Server/Components/Pages/Workflows/RunDetail.razor b/src/Werkr.Server/Components/Pages/Workflows/RunDetail.razor new file mode 100644 index 0000000..1076423 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Workflows/RunDetail.razor @@ -0,0 +1,158 @@ +@page "/workflows/runs/{RunId:guid}" +@using Werkr.Server.Services +@rendermode InteractiveServer +@attribute [Authorize] +@implements IAsyncDisposable +@inject IHttpClientFactory HttpClientFactory +@inject ServerConfigCache ConfigCache + +Werkr - Workflow Run Detail + +

Workflow Run Detail

+ + +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading ) { + +} else if ( _detail is not null ) { +
+
+
Run Summary
+
+
Run ID
+
@_detail.Id
+ +
Workflow
+
@_detail.WorkflowId
+ +
Status
+
+ @_detail.Status +
+ +
Started
+
@_detail.StartTime.ToString( "G" )
+ +
Ended
+
@( _detail.EndTime?.ToString( "G" ) ?? "Still running" )
+
+
+
+ +
+
+
Jobs (@_detail.Jobs.Count)
+ @if ( _detail.Jobs.Count > 0 ) { +
+ + + + + + + + + + + + + @foreach ( JobDto j in _detail.Jobs ) { + + + + + + + + + } + +
Job IDTask IDStartedRuntimeResultExit Code
@j.Id.ToString()[..8]…@j.TaskId@j.StartTime.ToString( "g" )@j.RuntimeSeconds.ToString( "F1" )s + + @( j.Success ? "Success" : "Failed" ) + + @( j.ExitCode?.ToString( ) ?? "—" )
+
+ } else { +

No jobs in this run.

+ } +
+
+ + ← Back to Runs +} + +@code { + [Parameter] public Guid RunId { get; set; } + + private WorkflowRunDetailDto? _detail; + private string? _errorMessage; + private bool _isLoading; + private bool _disposed; + private PeriodicTimer? _refreshTimer; + private CancellationTokenSource? _cts; + + private readonly List _breadcrumbs = [ + new( "Task List", "/" ), + new( "Workflows", "/workflows" ), + new( "Run Detail" ) + ]; + + protected override async Task OnInitializedAsync( ) { + await LoadAsync( ); + + _cts = new CancellationTokenSource( ); + _refreshTimer = new PeriodicTimer( TimeSpan.FromSeconds( ConfigCache.RunDetailPollingIntervalSeconds ) ); + _ = AutoRefreshAsync( _cts.Token ); + } + + private async Task AutoRefreshAsync( CancellationToken ct ) { + if ( _refreshTimer is null ) return; + try { + while ( await _refreshTimer.WaitForNextTickAsync( ct ) ) { + if ( _disposed ) return; + await LoadAsync( ); + await InvokeAsync( StateHasChanged ); + } + } catch ( OperationCanceledException ) { } + catch ( ObjectDisposedException ) { } + } + + private async Task LoadAsync( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _detail = await client.GetFromJsonAsync( + $"/api/workflows/runs/{RunId}" ); + if ( _detail is null ) { + _errorMessage = "Run not found."; + } + } catch ( Exception ex ) { + _errorMessage = $"Failed to load run: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private static string RunStatusBadge( string status ) => status switch { + "Completed" => "bg-success", + "Running" => "bg-primary", + "Failed" => "bg-danger", + "Cancelled" => "bg-warning text-dark", + _ => "bg-secondary" + }; + + public async ValueTask DisposeAsync( ) { + if ( _disposed ) return; + _disposed = true; + if ( _cts is not null ) { + await _cts.CancelAsync( ); + _cts.Dispose( ); + } + _refreshTimer?.Dispose( ); + } +} diff --git a/src/Werkr.Server/Components/Pages/Workflows/Runs.razor b/src/Werkr.Server/Components/Pages/Workflows/Runs.razor new file mode 100644 index 0000000..9a19382 --- /dev/null +++ b/src/Werkr.Server/Components/Pages/Workflows/Runs.razor @@ -0,0 +1,132 @@ +@page "/workflows/{WorkflowId:long}/runs" +@using Werkr.Server.Services +@rendermode InteractiveServer +@attribute [Authorize] +@implements IAsyncDisposable +@inject IHttpClientFactory HttpClientFactory +@inject ServerConfigCache ConfigCache + +Werkr - Workflow Runs + +

Workflow Runs

+ + +
+ + ← Back to Workflow + + +
+ +@if ( !string.IsNullOrWhiteSpace( _errorMessage ) ) { + +} + +@if ( _isLoading && _runs is null ) { + +} else if ( _runs is not null && _runs.Count > 0 ) { +
+ + + + + + + + + + + + @foreach ( WorkflowRunDto run in _runs ) { + + + + + + + + } + +
Run IDStartedEndedStatusActions
@run.Id.ToString()[..8]…@run.StartTime.ToString( "g" )@( run.EndTime?.ToString( "g" ) ?? "—" ) + @run.Status + + Details +
+
+} else if ( !_isLoading ) { + +} + +@code { + [Parameter] public long WorkflowId { get; set; } + + private List? _runs; + private string? _errorMessage; + private bool _isLoading; + private bool _disposed; + private PeriodicTimer? _refreshTimer; + private CancellationTokenSource? _cts; + + private List _breadcrumbs = []; + + protected override async Task OnInitializedAsync( ) { + _breadcrumbs = [ + new( "Task List", "/" ), + new( "Workflows", "/workflows" ), + new( $"Workflow {WorkflowId}", $"/workflows/{WorkflowId}" ), + new( "Runs" ) + ]; + await LoadAsync( ); + + _cts = new CancellationTokenSource( ); + _refreshTimer = new PeriodicTimer( TimeSpan.FromSeconds( ConfigCache.RunDetailPollingIntervalSeconds ) ); + _ = AutoRefreshAsync( _cts.Token ); + } + + private async Task AutoRefreshAsync( CancellationToken ct ) { + if ( _refreshTimer is null ) return; + try { + while ( await _refreshTimer.WaitForNextTickAsync( ct ) ) { + if ( _disposed ) return; + await LoadAsync( ); + await InvokeAsync( StateHasChanged ); + } + } catch ( OperationCanceledException ) { } + catch ( ObjectDisposedException ) { } + } + + private async Task LoadAsync( ) { + _isLoading = true; + _errorMessage = null; + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _runs = await client.GetFromJsonAsync>( + $"/api/workflows/{WorkflowId}/runs" ); + } catch ( Exception ex ) { + _errorMessage = $"Failed to load runs: {ex.Message}"; + } finally { + _isLoading = false; + } + } + + private static string RunStatusBadge( string status ) => status switch { + "Completed" => "bg-success", + "Running" => "bg-primary", + "Failed" => "bg-danger", + "Cancelled" => "bg-warning text-dark", + _ => "bg-secondary" + }; + + public ValueTask DisposeAsync( ) { + _disposed = true; + _cts?.Cancel( ); + _cts?.Dispose( ); + _refreshTimer?.Dispose( ); + return ValueTask.CompletedTask; + } +} diff --git a/src/Werkr.Server/Components/Routes.razor b/src/Werkr.Server/Components/Routes.razor new file mode 100644 index 0000000..c94fd91 --- /dev/null +++ b/src/Werkr.Server/Components/Routes.razor @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Werkr.Server/Components/Shared/ActionParameterEditor.razor b/src/Werkr.Server/Components/Shared/ActionParameterEditor.razor new file mode 100644 index 0000000..b604a6c --- /dev/null +++ b/src/Werkr.Server/Components/Shared/ActionParameterEditor.razor @@ -0,0 +1,236 @@ +@using Werkr.Server.Services +@using System.Text.Json +@rendermode InteractiveServer + +
+ + + @if ( _descriptor is not null ) { +
@_descriptor.Description
+ } +
+ +@if ( _descriptor is not null ) { +
+ +
+ + @if ( _jsonMode ) { +
+ + + @if ( !string.IsNullOrWhiteSpace( _jsonError ) ) { +
@_jsonError
+ } +
+ } else { + @foreach ( FieldDescriptor field in _descriptor.Fields ) { +
+ @switch ( field.Type ) { + case FieldType.Text: + + + break; + + case FieldType.TextArea: + + + break; + + case FieldType.Number: + + + break; + + case FieldType.Bool: +
+ + +
+ break; + + case FieldType.Select: + + + break; + } + @if ( !string.IsNullOrWhiteSpace( field.HelpText ) ) { +
@field.HelpText
+ } +
+ } + } +} + +@code { + [Parameter] public string ActionSubType { get; set; } = ""; + [Parameter] public EventCallback ActionSubTypeChanged { get; set; } + + /// Serialized JSON parameters string. + [Parameter] public string? ActionParameters { get; set; } + [Parameter] public EventCallback ActionParametersChanged { get; set; } + + private ActionFormDescriptor? _descriptor; + private Dictionary _values = new( StringComparer.OrdinalIgnoreCase ); + private bool _jsonMode; + private string _rawJson = ""; + private string? _jsonError; + + protected override void OnParametersSet( ) { + ResolveDescriptor( ); + if ( _descriptor is not null && !_jsonMode ) { + ParseParametersIntoValues( ); + } + } + + // ── Actions ────────────────────────────────────────────────────── + private async Task OnActionSelected( ChangeEventArgs e ) { + string key = e.Value?.ToString() ?? ""; + ActionSubType = key; + await ActionSubTypeChanged.InvokeAsync( key ); + + ResolveDescriptor( ); + _values.Clear( ); + + // Populate defaults + if ( _descriptor is not null ) { + foreach ( FieldDescriptor f in _descriptor.Fields ) { + if ( f.DefaultValue is not null ) { + _values[f.Name] = f.DefaultValue; + } + } + } + await EmitJson( ); + } + + private void ToggleJsonMode( ) { + _jsonMode = !_jsonMode; + if ( _jsonMode ) { + _rawJson = BuildJson( ); + _jsonError = null; + } else { + // Parse _rawJson back into _values + TryParseJsonIntoValues( _rawJson ); + } + } + + private async Task OnJsonEdited( ) { + _rawJson = _rawJson.Trim( ); + if ( TryParseJsonIntoValues( _rawJson ) ) { + _jsonError = null; + await EmitJson( ); + } else { + _jsonError = "Invalid JSON."; + } + } + + // ── Helpers ────────────────────────────────────────────────────── + private void ResolveDescriptor( ) { + _descriptor = !string.IsNullOrWhiteSpace( ActionSubType ) + && ActionParameterRegistry.Actions.TryGetValue( ActionSubType, out ActionFormDescriptor? desc ) + ? desc + : null; + } + + private string GetValue( string name ) + => _values.TryGetValue( name, out string? v ) ? v : ""; + + private async void SetBoolValue( string name, bool value ) { + _values[name] = value ? "true" : "false"; + await EmitJson( ); + } + + private async void SetValue( string name, string? value ) { + if ( string.IsNullOrEmpty( value ) ) { + _values.Remove( name ); + } else { + _values[name] = value; + } + await EmitJson( ); + } + + private async Task EmitJson( ) { + string json = BuildJson( ); + ActionParameters = json; + await ActionParametersChanged.InvokeAsync( json ); + } + + private string BuildJson( ) { + if ( _descriptor is null || _values.Count == 0 ) return "{}"; + + var obj = new Dictionary( ); + foreach ( FieldDescriptor field in _descriptor.Fields ) { + if ( !_values.TryGetValue( field.Name, out string? raw ) || string.IsNullOrEmpty( raw ) ) { + continue; + } + + object? typed = field.Type switch { + FieldType.Bool => raw.Equals( "true", StringComparison.OrdinalIgnoreCase ), + FieldType.Number => long.TryParse( raw, out long n ) ? n : raw, + _ => raw + }; + + obj[field.Name] = typed; + } + + return JsonSerializer.Serialize( obj, s_jsonOptions ); + } + + private void ParseParametersIntoValues( ) { + _values.Clear( ); + TryParseJsonIntoValues( ActionParameters ?? "" ); + } + + private bool TryParseJsonIntoValues( string json ) { + if ( string.IsNullOrWhiteSpace( json ) ) return true; + try { + using JsonDocument doc = JsonDocument.Parse( json ); + foreach ( JsonProperty prop in doc.RootElement.EnumerateObject( ) ) { + _values[prop.Name] = prop.Value.ValueKind switch { + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Number => prop.Value.GetRawText( ), + JsonValueKind.String => prop.Value.GetString( ) ?? "", + _ => prop.Value.GetRawText( ) + }; + } + return true; + } catch { + return false; + } + } + + private static RenderFragment RequiredStar( FieldDescriptor f ) => + f.Required ? @* : @; + + private static readonly JsonSerializerOptions s_jsonOptions = new( ) { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; +} diff --git a/src/Werkr.Server/Components/Shared/Breadcrumb.razor b/src/Werkr.Server/Components/Shared/Breadcrumb.razor new file mode 100644 index 0000000..489f858 --- /dev/null +++ b/src/Werkr.Server/Components/Shared/Breadcrumb.razor @@ -0,0 +1,19 @@ + + +@code { + [Parameter] + public List Items { get; set; } = []; + + public sealed record BreadcrumbItem( string Label, string Href = "#" ); +} diff --git a/src/Werkr.Server/Components/Shared/ConfirmDialog.razor b/src/Werkr.Server/Components/Shared/ConfirmDialog.razor new file mode 100644 index 0000000..b67f771 --- /dev/null +++ b/src/Werkr.Server/Components/Shared/ConfirmDialog.razor @@ -0,0 +1,55 @@ +@* Confirmation dialog rendered as a Bootstrap modal. *@ + +@if ( _isVisible ) { + +} + +@code { + private bool _isVisible; + + /// Modal title. + [Parameter] public string Title { get; set; } = "Confirm"; + + /// Body message. + [Parameter] public string Message { get; set; } = "Are you sure?"; + + /// Bootstrap level for the confirm button (danger, primary, etc.). + [Parameter] public string ConfirmButtonLevel { get; set; } = "danger"; + + /// Text on the confirm button. + [Parameter] public string ConfirmButtonText { get; set; } = "Confirm"; + + /// Callback invoked when confirmed. + [Parameter] public EventCallback OnConfirmed { get; set; } + + /// Show the dialog. + public void Show( ) { + _isVisible = true; + StateHasChanged( ); + } + + private async Task Confirm( ) { + _isVisible = false; + await OnConfirmed.InvokeAsync( ); + } + + private void Cancel( ) { + _isVisible = false; + } +} diff --git a/src/Werkr.Server/Components/Shared/DagView.razor b/src/Werkr.Server/Components/Shared/DagView.razor new file mode 100644 index 0000000..ba3da50 --- /dev/null +++ b/src/Werkr.Server/Components/Shared/DagView.razor @@ -0,0 +1,195 @@ +@using System.Text +@inject IHttpClientFactory HttpClientFactory + +
+ @if ( Steps is null || Steps.Count == 0 ) { + + } else if ( _nodes.Count == 0 ) { + + } else { + + + + + + + + @foreach ( DagEdge edge in _edges ) { + + } + + @foreach ( DagNode node in _nodes ) { + + + + @Truncate( node.Label, 16 ) + + + Order @node.Step.Order | @node.Step.ControlStatement + + + } + + } +
+ +@code { + [Parameter] + public long WorkflowId { get; set; } + + [Parameter] + public IReadOnlyList? Steps { get; set; } + + [Parameter] + public EventCallback OnStepSelected { get; set; } + + private const int NodeWidth = 160; + private const int NodeHeight = 52; + private const int HorizontalGap = 40; + private const int VerticalGap = 60; + private const int PaddingX = 20; + private const int PaddingY = 20; + + private List _nodes = []; + private List _edges = []; + private int _svgWidth; + private int _svgHeight; + + protected override void OnParametersSet( ) { + if ( Steps is not null ) { + BuildLayout( Steps ); + } + } + + private void BuildLayout( IReadOnlyList steps ) { + if ( steps.Count == 0 ) { + _nodes = []; + _edges = []; + _svgWidth = 0; + _svgHeight = 0; + return; + } + + // Build adjacency and in-degree for topological layering + Dictionary stepMap = steps.ToDictionary( s => s.Id ); + Dictionary> dependsOn = []; + Dictionary> dependedBy = []; + + foreach ( WorkflowStepDto step in steps ) { + dependsOn[step.Id] = []; + if ( !dependedBy.ContainsKey( step.Id ) ) { + dependedBy[step.Id] = []; + } + foreach ( StepDependencyDto dep in step.Dependencies ) { + dependsOn[step.Id].Add( dep.DependsOnStepId ); + if ( !dependedBy.ContainsKey( dep.DependsOnStepId ) ) { + dependedBy[dep.DependsOnStepId] = []; + } + dependedBy[dep.DependsOnStepId].Add( step.Id ); + } + } + + // Kahn's algorithm for topological layers + Dictionary inDegree = []; + foreach ( WorkflowStepDto step in steps ) { + inDegree[step.Id] = dependsOn[step.Id].Count; + } + + List> layers = []; + HashSet placed = []; + + while ( placed.Count < steps.Count ) { + List layer = []; + foreach ( WorkflowStepDto step in steps ) { + if ( !placed.Contains( step.Id ) && inDegree[step.Id] == 0 ) { + layer.Add( step.Id ); + } + } + + if ( layer.Count == 0 ) { + // Cycle detected — place all remaining + layer = steps.Where( s => !placed.Contains( s.Id ) ) + .Select( s => s.Id ) + .ToList( ); + } + + // Sort layer by step order for consistent rendering + layer.Sort( ( a, b ) => stepMap[a].Order.CompareTo( stepMap[b].Order ) ); + + foreach ( long id in layer ) { + placed.Add( id ); + if ( dependedBy.TryGetValue( id, out HashSet? children ) ) { + foreach ( long child in children ) { + inDegree[child]--; + } + } + } + + layers.Add( layer ); + } + + // Position nodes in layers (top to bottom, each layer is a row) + Dictionary nodePositions = []; + _nodes = []; + int maxLayerWidth = 0; + + for ( int layerIdx = 0; layerIdx < layers.Count; layerIdx++ ) { + List layer = layers[layerIdx]; + maxLayerWidth = Math.Max( maxLayerWidth, layer.Count ); + + for ( int colIdx = 0; colIdx < layer.Count; colIdx++ ) { + long stepId = layer[colIdx]; + WorkflowStepDto step = stepMap[stepId]; + int x = PaddingX + colIdx * ( NodeWidth + HorizontalGap ); + int y = PaddingY + layerIdx * ( NodeHeight + VerticalGap ); + string label = $"#{step.Id} T{step.TaskId}"; + DagNode node = new( step, label, x, y ); + _nodes.Add( node ); + nodePositions[stepId] = node; + } + } + + // Build edges + _edges = []; + foreach ( WorkflowStepDto step in steps ) { + if ( !nodePositions.TryGetValue( step.Id, out DagNode? target ) ) continue; + foreach ( StepDependencyDto dep in step.Dependencies ) { + if ( !nodePositions.TryGetValue( dep.DependsOnStepId, out DagNode? source ) ) continue; + _edges.Add( new DagEdge( + source.X + NodeWidth / 2, + source.Y + NodeHeight, + target.X + NodeWidth / 2, + target.Y ) ); + } + } + + _svgWidth = PaddingX * 2 + maxLayerWidth * ( NodeWidth + HorizontalGap ) - HorizontalGap; + _svgHeight = PaddingY * 2 + layers.Count * ( NodeHeight + VerticalGap ) - VerticalGap; + + if ( _svgWidth < 200 ) _svgWidth = 200; + if ( _svgHeight < 100 ) _svgHeight = 100; + } + + private static string GetNodeFill( WorkflowStepDto step ) => step.ControlStatement.ToLowerInvariant( ) switch { + "sequential" => "#0d6efd", // Blue — normal sequential execution + "if" or "elseif" => "#6f42c1", // Purple — conditional branch + "else" => "#9b59b6", // Light purple — fallback branch + "while" or "do" => "#fd7e14", // Orange — loop + _ => "#495057" // Gray — unknown + }; + + private static string Truncate( string text, int max ) => + text.Length <= max ? text : text[..max] + "…"; + + private sealed record DagNode( WorkflowStepDto Step, string Label, int X, int Y ); + private sealed record DagEdge( int X1, int Y1, int X2, int Y2 ); +} diff --git a/src/Werkr.Server/Components/Shared/DagView.razor.css b/src/Werkr.Server/Components/Shared/DagView.razor.css new file mode 100644 index 0000000..c471f3d --- /dev/null +++ b/src/Werkr.Server/Components/Shared/DagView.razor.css @@ -0,0 +1,22 @@ +.dag-container { + overflow-x: auto; + overflow-y: auto; + max-height: 500px; + border: 1px solid var(--bs-border-color); + border-radius: 0.375rem; + padding: 0.5rem; + background: var(--bs-body-bg); +} + +.dag-svg { + display: block; +} + +::deep .dag-node { + cursor: pointer; +} + +::deep .dag-node:hover rect { + stroke-width: 2.5; + stroke: var(--bs-primary); +} diff --git a/src/Werkr.Server/Components/Shared/EmptyState.razor b/src/Werkr.Server/Components/Shared/EmptyState.razor new file mode 100644 index 0000000..d6746a2 --- /dev/null +++ b/src/Werkr.Server/Components/Shared/EmptyState.razor @@ -0,0 +1,11 @@ +@* Displays a placeholder message when a list has no data. *@ + +
+ +
@Message
+
+ +@code { + /// Message shown when no items are available. + [Parameter] public string Message { get; set; } = "No items found."; +} diff --git a/src/Werkr.Server/Components/Shared/LoadingSpinner.razor b/src/Werkr.Server/Components/Shared/LoadingSpinner.razor new file mode 100644 index 0000000..9b0a563 --- /dev/null +++ b/src/Werkr.Server/Components/Shared/LoadingSpinner.razor @@ -0,0 +1,15 @@ +@* Displays a centered spinner with optional message. *@ + +
+
+ Loading... +
+ @if ( !string.IsNullOrWhiteSpace( Message ) ) { + @Message + } +
+ +@code { + /// Optional status message displayed next to the spinner. + [Parameter] public string? Message { get; set; } +} diff --git a/src/Werkr.Server/Components/Shared/StatusBanner.razor b/src/Werkr.Server/Components/Shared/StatusBanner.razor new file mode 100644 index 0000000..3015d09 --- /dev/null +++ b/src/Werkr.Server/Components/Shared/StatusBanner.razor @@ -0,0 +1,54 @@ +@* Reusable alert banner for displaying contextual status/error messages. *@ + +@if (Visible) { + +} + +@code { + /// Whether the banner is visible. + [Parameter] public bool Visible { get; set; } + + /// Bootstrap alert level: danger, warning, info, success. + [Parameter] public string Level { get; set; } = "warning"; + + /// Bold title line. + [Parameter] public string? Title { get; set; } + + /// Body message text. + [Parameter] public string? Message { get; set; } + + /// Whether the user can dismiss the banner. + [Parameter] public bool Dismissible { get; set; } = true; + + /// Additional CSS classes to apply. + [Parameter] public string? CssClass { get; set; } + + /// Callback when the banner is dismissed. + [Parameter] public EventCallback OnDismissed { get; set; } + + private string IconClass => Level switch { + "danger" => "bi bi-x-circle-fill", + "warning" => "bi bi-exclamation-triangle-fill", + "success" => "bi bi-check-circle-fill", + "info" => "bi bi-info-circle-fill", + _ => "bi bi-info-circle-fill" + }; + + private async Task Dismiss( ) { + Visible = false; + await OnDismissed.InvokeAsync( ); + } +} diff --git a/src/Werkr.Server/Components/Shared/TagInput.razor b/src/Werkr.Server/Components/Shared/TagInput.razor new file mode 100644 index 0000000..85cf090 --- /dev/null +++ b/src/Werkr.Server/Components/Shared/TagInput.razor @@ -0,0 +1,123 @@ +@rendermode InteractiveServer +@inject IHttpClientFactory HttpClientFactory + +
+ @foreach (string tag in Value) { + + @tag + + + } + @if (Value.Length == 0) { + No tags assigned. + } +
+ +
+
+ + +
+ + @if (_showSuggestions && FilteredSuggestions.Count > 0) { +
+ @foreach (string suggestion in FilteredSuggestions) { + + } +
+ } +
+ +@code { + /// Current tags array — two-way bound. + [Parameter] + public string[] Value { get; set; } = []; + + /// Callback when tags change. + [Parameter] + public EventCallback ValueChanged { get; set; } + + private string _inputText = string.Empty; + private bool _showSuggestions; + private List _allTags = []; + + private List FilteredSuggestions { + get { + if (string.IsNullOrWhiteSpace( _inputText )) { + // Show all tags not already selected + return [.. _allTags.Where( t => !Value.Contains( t, StringComparer.OrdinalIgnoreCase ) )]; + } + + return [.. _allTags + .Where( t => t.Contains( _inputText, StringComparison.OrdinalIgnoreCase ) + && !Value.Contains( t, StringComparer.OrdinalIgnoreCase ) )]; + } + } + + protected override async Task OnInitializedAsync( ) { + try { + HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); + _allTags = await client.GetFromJsonAsync>( "/api/tags" ) ?? []; + } catch { + _allTags = []; + } + } + + private void ShowSuggestions( ) { + _showSuggestions = true; + } + + private async Task OnBlurAsync( ) { + // Delay to allow click on suggestion to register + await Task.Delay( 200 ); + _showSuggestions = false; + } + + private async Task OnKeyDown( KeyboardEventArgs e ) { + if (e.Key == "Enter") { + await AddCurrentTag( ); + } else if (e.Key == "Escape") { + _showSuggestions = false; + } + } + + private async Task AddCurrentTag( ) { + string trimmed = _inputText.Trim( ); + if (string.IsNullOrEmpty( trimmed ) || Value.Contains( trimmed, StringComparer.OrdinalIgnoreCase )) { + _inputText = string.Empty; + return; + } + + _inputText = string.Empty; + _showSuggestions = false; + string[] updated = [.. Value, trimmed]; + Value = updated; + await ValueChanged.InvokeAsync( updated ); + } + + private async Task RemoveTag( string tag ) { + string[] updated = [.. Value.Where( t => !t.Equals( tag, StringComparison.OrdinalIgnoreCase ) )]; + Value = updated; + await ValueChanged.InvokeAsync( updated ); + } + + private async Task SelectSuggestion( string tag ) { + if (Value.Contains( tag, StringComparer.OrdinalIgnoreCase )) { + return; + } + + _inputText = string.Empty; + _showSuggestions = false; + string[] updated = [.. Value, tag]; + Value = updated; + await ValueChanged.InvokeAsync( updated ); + } +} diff --git a/src/Werkr.Server/Components/Shared/ToggleSwitch.razor b/src/Werkr.Server/Components/Shared/ToggleSwitch.razor new file mode 100644 index 0000000..0cba6ee --- /dev/null +++ b/src/Werkr.Server/Components/Shared/ToggleSwitch.razor @@ -0,0 +1,29 @@ +@* Toggle switch for enabled/disabled state. Renders a Bootstrap form-check with a switch. *@ + +
+ + +
+ +@code { + private readonly string _inputId = $"toggle-{Guid.NewGuid():N}"; + + /// Current switch value. + [Parameter] public bool Value { get; set; } + + /// Two-way binding callback. + [Parameter] public EventCallback ValueChanged { get; set; } + + /// Label next to the switch. + [Parameter] public string Label { get; set; } = "Enabled"; + + /// Disable interaction. + [Parameter] public bool Disabled { get; set; } + + private async Task OnToggle( ChangeEventArgs e ) { + bool newValue = (bool) ( e.Value ?? false ); + Value = newValue; + await ValueChanged.InvokeAsync( newValue ); + } +} diff --git a/src/Werkr.Server/Components/_Imports.razor b/src/Werkr.Server/Components/_Imports.razor new file mode 100644 index 0000000..5d8815a --- /dev/null +++ b/src/Werkr.Server/Components/_Imports.razor @@ -0,0 +1,15 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.OutputCaching +@using Microsoft.JSInterop +@using Werkr.Common.Models +@using Werkr.Server.Components +@using Werkr.Server.Components.Shared +@using Werkr.Server.Utilities diff --git a/src/Werkr.Server/Dockerfile b/src/Werkr.Server/Dockerfile new file mode 100644 index 0000000..e766713 --- /dev/null +++ b/src/Werkr.Server/Dockerfile @@ -0,0 +1,106 @@ +# syntax=docker/dockerfile:1 +# ------------------------------------------------------------------- +# Werkr.Server — Blazor Server UI +# +# Two build modes (controlled by BUILD_MODE arg): +# source (default) — builds from source using the SDK image +# deb — installs pre-built .deb (ServerBundle) from Publish/ +# +# For .deb mode, run publish.ps1 first: +# pwsh scripts/publish.ps1 -Application ServerBundle -Platform linux -Architecture x64 -BuildDebInstallers +# docker compose build --build-arg BUILD_MODE=deb +# +# Build context: repository root (Werkr_Final/) +# ------------------------------------------------------------------- + +ARG BUILD_MODE=source + +# ========================== +# Source build stage +# ========================== +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS source-build +ARG TARGETARCH +WORKDIR /src + +COPY Directory.Build.props Directory.Packages.props global.json Werkr.slnx ./ + +COPY src/Werkr.ServiceDefaults/Werkr.ServiceDefaults.csproj src/Werkr.ServiceDefaults/ +COPY src/Werkr.Common.Configuration/Werkr.Common.Configuration.csproj src/Werkr.Common.Configuration/ +COPY src/Werkr.Common/Werkr.Common.csproj src/Werkr.Common/ +COPY src/Werkr.Core/Werkr.Core.csproj src/Werkr.Core/ +COPY src/Werkr.Data/Werkr.Data.csproj src/Werkr.Data/ +COPY src/Werkr.Data.Identity/Werkr.Data.Identity.csproj src/Werkr.Data.Identity/ +COPY src/Werkr.Server/Werkr.Server.csproj src/Werkr.Server/ + +# Proto files needed during restore/build for gRPC codegen +COPY src/Werkr.Agent/Protos/ src/Werkr.Agent/Protos/ + +RUN dotnet restore src/Werkr.Server/Werkr.Server.csproj \ + -r linux-${TARGETARCH} + +COPY src/ src/ +RUN dotnet publish src/Werkr.Server/Werkr.Server.csproj \ + -c Release -r linux-${TARGETARCH} -o /app/publish \ + --sc true + +# ========================== +# Source runtime +# ========================== +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS source + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system werkr \ + && useradd --system --no-create-home -g werkr werkr + +WORKDIR /app + +# Create volume-mount directories while still root so ownership +# is baked into the image layer. Docker named volumes copy these +# permissions on first mount — no runtime chown/gosu needed. +RUN mkdir -p /app/keys /app/config /app/certs \ + && chown -R werkr:werkr /app/keys /app/config /app/certs + +COPY --from=source-build --chown=werkr:werkr /app/publish . + +EXPOSE 8443 + +ENV ASPNETCORE_URLS=https://+:8443 \ + ASPNETCORE_ENVIRONMENT=Production \ + DOTNET_ENVIRONMENT=Production + +USER werkr +ENTRYPOINT ["./Werkr.Server"] + +# ========================== +# Deb runtime +# ========================== +FROM debian:bookworm-slim AS deb + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl libicu72 libssl3 \ + && rm -rf /var/lib/apt/lists/* + +COPY Publish/Werkr.ServerBundle.*.deb /tmp/ +RUN dpkg -x /tmp/*.deb / && rm /tmp/*.deb + +RUN groupadd --system werkr \ + && useradd --system --no-create-home -g werkr werkr \ + && mkdir -p /opt/werkr/serverbundle/keys /opt/werkr/serverbundle/config /opt/werkr/serverbundle/certs \ + && chown -R werkr:werkr /opt/werkr/serverbundle/keys /opt/werkr/serverbundle/config /opt/werkr/serverbundle/certs + +USER werkr +WORKDIR /opt/werkr/serverbundle + +EXPOSE 8443 + +ENV ASPNETCORE_URLS=https://+:8443 \ + ASPNETCORE_ENVIRONMENT=Production \ + DOTNET_ENVIRONMENT=Production + +ENTRYPOINT ["./Werkr.Server"] + +# ========================== +# Final (selects mode via BUILD_MODE arg) +# ========================== +FROM ${BUILD_MODE} AS final diff --git a/src/Werkr.Server/Endpoints/AuthEndpoints.cs b/src/Werkr.Server/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..6a9ef28 --- /dev/null +++ b/src/Werkr.Server/Endpoints/AuthEndpoints.cs @@ -0,0 +1,101 @@ +using System.Security.Claims; + +using Werkr.Common.Auth; +using Werkr.Common.Models; +using Werkr.Data.Identity.Entities; +using Werkr.Data.Identity.Services; +using Werkr.Server.Identity; + +namespace Werkr.Server.Endpoints; + +/// +/// Maps authentication and API key management endpoints on the Server. +/// The Server is the sole JWT token issuer (Decision A1). +/// +public static class AuthEndpoints { + /// + /// Maps auth-related endpoints: token exchange and API key CRUD. + /// + public static WebApplication MapAuthEndpoints( this WebApplication app ) { + // ── Token Exchange (unauthenticated) ── + + _ = app.MapPost( "/api/auth/token", async ( + TokenRequest request, + ApiKeyService apiKeyService, + JwtTokenService tokenService, + IPermissionService permissionService, + CancellationToken ct ) => { + if (string.IsNullOrWhiteSpace( request.ApiKey )) { + return Results.BadRequest( new { message = "API key is required." } ); + } + + ApiKey? apiKey = await apiKeyService.ValidateAsync( request.ApiKey, ct ); + if (apiKey is null) { + return Results.Unauthorized( ); + } + + IReadOnlyList permissions = + await permissionService.GetPermissionsForRoleAsync( apiKey.Role, ct ); + string token = tokenService.GenerateToken( apiKey, permissions ); + DateTime expiresUtc = DateTime.UtcNow.AddMinutes( 15 ); + return Results.Ok( new TokenResponse( token, expiresUtc ) ); + } ) + .WithName( "ExchangeApiKeyForToken" ) + .WithTags( "Auth" ) + .AllowAnonymous( ); + + // ── API Key Management ── + + _ = app.MapPost( "/api/auth/keys", async ( + ApiKeyCreateRequest request, + ApiKeyService apiKeyService, + ClaimsPrincipal user, + CancellationToken ct ) => { + string? userId = user.FindFirst( ClaimTypes.NameIdentifier )?.Value; + if (string.IsNullOrWhiteSpace( userId )) { + return Results.Unauthorized( ); + } + + string? userRole = user.FindFirst( ClaimTypes.Role )?.Value; + if (string.IsNullOrWhiteSpace( userRole )) { + return Results.Forbid( ); + } + + (ApiKey apiKey, string rawKey) = await apiKeyService.CreateAsync( + request.Name, userRole, userId, request.ExpiresUtc, ct ); + + return Results.Created( $"/api/auth/keys/{apiKey.Id}", new ApiKeyCreateResponse( + apiKey.Id, apiKey.Name, rawKey, apiKey.KeyPrefix, apiKey.Role, + apiKey.CreatedUtc, apiKey.ExpiresUtc ) ); + } ) + .WithName( "CreateApiKey" ) + .WithTags( "Auth" ) + .RequireAuthorization( Policies.IsAdmin ); + + _ = app.MapGet( "/api/auth/keys", async ( + ApiKeyService apiKeyService, + CancellationToken ct ) => { + IReadOnlyList keys = await apiKeyService.GetAllAsync( ct ); + List dtos = [.. keys.Select( k => new ApiKeyDto( + k.Id, k.Name, k.KeyPrefix, k.Role, k.CreatedByUserId, + k.CreatedUtc, k.ExpiresUtc, k.IsRevoked, k.LastUsedUtc ) )]; + return Results.Ok( dtos ); + } ) + .WithName( "ListApiKeys" ) + .WithTags( "Auth" ) + .RequireAuthorization( Policies.IsAdmin ); + + _ = app.MapDelete( "/api/auth/keys/{id}", async ( + Guid id, + ApiKeyService apiKeyService, + CancellationToken ct ) => { + bool revoked = await apiKeyService.RevokeAsync( id, ct ); + return revoked ? Results.NoContent( ) : Results.NotFound( ); + } ) + .WithName( "RevokeApiKey" ) + .WithTags( "Auth" ) + .RequireAuthorization( Policies.IsAdmin ); + + return app; + } +} diff --git a/src/Werkr.Server/Helpers/AgentDisplayHelper.cs b/src/Werkr.Server/Helpers/AgentDisplayHelper.cs new file mode 100644 index 0000000..aa36074 --- /dev/null +++ b/src/Werkr.Server/Helpers/AgentDisplayHelper.cs @@ -0,0 +1,51 @@ +namespace Werkr.Server.Helpers; + +/// +/// Consolidated UI helpers for agent status display. +/// Replaces duplicated GetStatusBadgeClass, FormatAvailability, +/// and FormatRelativeTime methods spread across multiple pages. +/// +public static class AgentDisplayHelper { + + /// + /// Returns the Bootstrap badge CSS class for a given agent status string. + /// + public static string GetStatusBadgeClass( string status ) => + status switch { + "Connected" => "bg-success", + "Disconnected" => "bg-warning text-dark", + "Error" => "bg-danger", + "Revoked" => "bg-secondary", + "Unreachable" => "bg-danger", + "Registered" => "bg-info", + _ => "bg-info" + }; + + /// + /// Formats an operator availability nullable boolean as a display string. + /// + public static string FormatAvailability( bool? value ) => + value switch { + true => "Available", + false => "Unavailable", + null => "Unknown" + }; + + /// + /// Formats a nullable UTC timestamp as a human-readable relative time string. + /// + public static string FormatRelativeTime( DateTime? timestamp ) { + if (!timestamp.HasValue) { + return "Never"; + } + + TimeSpan elapsed = DateTime.UtcNow - timestamp.Value.ToUniversalTime( ); + return elapsed.TotalMinutes < 1 + ? "Just now" + : elapsed.TotalHours < 1 + ? $"{Math.Max( 1, (int)elapsed.TotalMinutes )} min ago" + : elapsed.TotalDays < 1 + ? $"{Math.Max( 1, (int)elapsed.TotalHours )} hr ago" + : $"{Math.Max( 1, (int)elapsed.TotalDays )} day(s) ago"; + } +} diff --git a/src/Werkr.Server/Helpers/UrlValidator.cs b/src/Werkr.Server/Helpers/UrlValidator.cs new file mode 100644 index 0000000..0b8e9d2 --- /dev/null +++ b/src/Werkr.Server/Helpers/UrlValidator.cs @@ -0,0 +1,29 @@ +namespace Werkr.Server.Helpers; + +/// +/// Validates return URLs to prevent open redirect vulnerabilities. +/// +public static class UrlValidator { + /// + /// Returns true only for local relative URLs. + /// + /// The candidate URL. + /// True when URL is safe and local. + public static bool IsLocalUrl( string? url ) { + if (string.IsNullOrWhiteSpace( url )) { + return false; + } + + if (!Uri.IsWellFormedUriString( url, UriKind.Relative )) { + return false; + } + + if (url.StartsWith( "//", StringComparison.Ordinal )) { + return false; + } + + int firstSlash = url.IndexOf( '/' ); + int colonIndex = url.IndexOf( ':' ); + return colonIndex < 0 || (firstSlash >= 0 && colonIndex >= firstSlash); + } +} diff --git a/src/Werkr.Server/Identity/ApiKeyService.cs b/src/Werkr.Server/Identity/ApiKeyService.cs new file mode 100644 index 0000000..5e16308 --- /dev/null +++ b/src/Werkr.Server/Identity/ApiKeyService.cs @@ -0,0 +1,155 @@ +using System.Security.Cryptography; + +using Microsoft.EntityFrameworkCore; + +using Werkr.Data.Identity; +using Werkr.Data.Identity.Entities; + +namespace Werkr.Server.Identity; + +/// +/// Service for creating, validating, and managing API keys. +/// API keys are stored as SHA-256 hashes; the raw key is only returned at creation time. +/// +public sealed class ApiKeyService( WerkrIdentityDbContext dbContext, ILogger logger ) { + + /// + /// Creates a new API key for the specified user with the given role. + /// + /// Human-readable name for the key. + /// The role to assign to tokens generated from this key. + /// The user ID of the creator. + /// Optional expiration date. + /// Cancellation token. + /// A tuple of the created entity and the raw key (only available at creation time). + public async Task<(ApiKey Entity, string RawKey)> CreateAsync( + string name, + string role, + string createdByUserId, + DateTime? expiresUtc = null, + CancellationToken ct = default ) { + // Generate a crypto-random 32-byte key, encoded as base64url + byte[] keyBytes = RandomNumberGenerator.GetBytes( 32 ); + string rawKey = $"wk_{Convert.ToBase64String( keyBytes ).TrimEnd( '=' ).Replace( '+', '-' ).Replace( '/', '_' )}"; + + string keyHash = ComputeHash( rawKey ); + string keyPrefix = rawKey[..Math.Min( 12, rawKey.Length )]; + + ApiKey apiKey = new( ) { + Id = Guid.NewGuid( ), + KeyHash = keyHash, + KeyPrefix = keyPrefix, + Name = name.Trim( ), + Role = role, + CreatedByUserId = createdByUserId, + CreatedUtc = DateTime.UtcNow, + ExpiresUtc = expiresUtc, + IsRevoked = false, + }; + + _ = dbContext.ApiKeys.Add( apiKey ); + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "API key '{Name}' (prefix: {Prefix}) created for user {UserId} with role {Role}.", + apiKey.Name, apiKey.KeyPrefix, createdByUserId, role ); + } + + return (apiKey, rawKey); + } + + /// + /// Validates a raw API key and returns the corresponding entity if valid. + /// Updates on successful validation. + /// + /// The raw API key to validate. + /// Cancellation token. + /// The API key entity if valid; otherwise null. + public async Task ValidateAsync( string rawKey, CancellationToken ct = default ) { + if (string.IsNullOrWhiteSpace( rawKey )) { + return null; + } + + string keyHash = ComputeHash( rawKey ); + + ApiKey? apiKey = await dbContext.ApiKeys + .FirstOrDefaultAsync( k => k.KeyHash == keyHash, ct ); + + if (apiKey is null) { + return null; + } + + if (apiKey.IsRevoked) { + logger.LogWarning( "Attempt to use revoked API key '{Name}' (prefix: {Prefix}).", + apiKey.Name, apiKey.KeyPrefix ); + return null; + } + + if (apiKey.ExpiresUtc.HasValue && apiKey.ExpiresUtc.Value < DateTime.UtcNow) { + logger.LogWarning( "Attempt to use expired API key '{Name}' (prefix: {Prefix}).", + apiKey.Name, apiKey.KeyPrefix ); + return null; + } + + // Update last used timestamp + apiKey.LastUsedUtc = DateTime.UtcNow; + _ = await dbContext.SaveChangesAsync( ct ); + + return apiKey; + } + + /// + /// Revokes an API key by ID. + /// + /// The API key ID. + /// Cancellation token. + /// true if the key was found and revoked; otherwise false. + public async Task RevokeAsync( Guid keyId, CancellationToken ct = default ) { + ApiKey? apiKey = await dbContext.ApiKeys.FindAsync( [keyId], ct ); + if (apiKey is null) { + return false; + } + + apiKey.IsRevoked = true; + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "API key '{Name}' (prefix: {Prefix}) revoked.", apiKey.Name, apiKey.KeyPrefix ); + } + return true; + } + + /// + /// Gets all API keys (without the raw key value). + /// + /// Cancellation token. + /// List of API key entities. + public async Task> GetAllAsync( CancellationToken ct = default ) { + return await dbContext.ApiKeys + .AsNoTracking( ) + .OrderByDescending( k => k.CreatedUtc ) + .ToListAsync( ct ); + } + + /// + /// Gets API keys created by a specific user. + /// + /// The user ID. + /// Cancellation token. + /// List of API key entities. + public async Task> GetByUserAsync( string userId, CancellationToken ct = default ) { + return await dbContext.ApiKeys + .AsNoTracking( ) + .Where( k => k.CreatedByUserId == userId ) + .OrderByDescending( k => k.CreatedUtc ) + .ToListAsync( ct ); + } + + /// + /// Computes a SHA-256 hash of the raw API key. + /// + private static string ComputeHash( string rawKey ) { + byte[] hash = SHA256.HashData( System.Text.Encoding.UTF8.GetBytes( rawKey ) ); + return Convert.ToHexString( hash ); + } +} diff --git a/src/Werkr.Server/Identity/AuthForwardingHandler.cs b/src/Werkr.Server/Identity/AuthForwardingHandler.cs new file mode 100644 index 0000000..aad9bde --- /dev/null +++ b/src/Werkr.Server/Identity/AuthForwardingHandler.cs @@ -0,0 +1,36 @@ +using System.Net.Http.Headers; + +namespace Werkr.Server.Identity; + +/// +/// Delegating handler that attaches a self-minted JWT bearer token to +/// outgoing API requests from the Blazor Server. The Server is the sole +/// JWT issuer and trusts itself — no HTTP round-trip is needed (Decision A1). +/// +public sealed class AuthForwardingHandler : DelegatingHandler { + private readonly JwtTokenService _tokenService; + private readonly ILogger _logger; + + /// + /// Initializes the auth forwarding handler. + /// + public AuthForwardingHandler( + JwtTokenService tokenService, + ILogger logger ) { + _tokenService = tokenService; + _logger = logger; + } + + /// + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken ) { + string token = _tokenService.GenerateServiceToken( ); + request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", token ); + + if (_logger.IsEnabled( LogLevel.Debug )) { + _logger.LogDebug( "Attached self-minted service JWT to outgoing API request." ); + } + + return base.SendAsync( request, cancellationToken ); + } +} diff --git a/src/Werkr.Server/Identity/IdentitySeeder.cs b/src/Werkr.Server/Identity/IdentitySeeder.cs new file mode 100644 index 0000000..eb5e311 --- /dev/null +++ b/src/Werkr.Server/Identity/IdentitySeeder.cs @@ -0,0 +1,170 @@ +using System.Security.Cryptography; + +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +using Werkr.Common.Auth; +using Werkr.Data.Identity; +using Werkr.Data.Identity.Entities; +using Werkr.Data.Identity.Roles; + +namespace Werkr.Server.Identity; + +/// +/// Seeds default roles, role-permission mappings, and an initial admin account on application startup. +/// +public static class IdentitySeeder { + /// + /// Default permission sets for each role. + /// Admin gets all permissions. Operator gets Read + Execute. Viewer gets Read only. + /// + private static readonly Dictionary s_defaultPermissions = new( ) { + [DefaultRoles.Admin] = [ + Permission.Create, + Permission.Read, + Permission.Update, + Permission.Delete, + Permission.Execute, + Permission.Admin, + ], + [DefaultRoles.Operator] = [ + Permission.Read, + Permission.Execute, + ], + [DefaultRoles.Viewer] = [ + Permission.Read, + ], + }; + + /// + /// Seeds default roles, role-permission mappings, and an initial admin account if none exists. + /// Called during application startup. + /// + /// The application's root . + public static async Task SeedAsync( IServiceProvider services ) { + using IServiceScope scope = services.CreateScope( ); + RoleManager roleManager = scope.ServiceProvider + .GetRequiredService>( ); + UserManager userManager = scope.ServiceProvider + .GetRequiredService>( ); + WerkrIdentityDbContext dbContext = scope.ServiceProvider + .GetRequiredService( ); + + // Seed roles + foreach (string role in Enum.GetNames( )) { + if (!await roleManager.RoleExistsAsync( role )) { + _ = await roleManager.CreateAsync( new IdentityRole( role ) ); + } + } + + // Seed role-permission mappings + await SeedPermissionsAsync( roleManager, dbContext ); + + // Seed default admin if no admin exists + IList admins = await userManager.GetUsersInRoleAsync( + DefaultRoles.Admin.ToString( ) ); + + if (admins.Count == 0) { + string generatedPassword = GenerateDefaultAdminPassword( ); + + WerkrUser admin = new( ) { + UserName = "admin@werkr.local", + Email = "admin@werkr.local", + Name = "Default Admin", + Enabled = true, + ChangePassword = true, + Requires2FA = true, + EmailConfirmed = true + }; + + IdentityResult result = await userManager.CreateAsync( admin, generatedPassword ); + if (result.Succeeded) { + _ = await userManager.AddToRoleAsync( admin, DefaultRoles.Admin.ToString( ) ); + + ILogger logger = services.GetRequiredService( ) + .CreateLogger( "Werkr.Identity.Seeder" ); + logger.LogWarning( "Default admin account created: admin@werkr.local — change the password on first login." ); + + // Write sensitive credentials only to stdout (not to Serilog sinks) + Console.WriteLine( ); + Console.WriteLine( "╔══════════════════════════════════════════════════════╗" ); + Console.WriteLine( "║ DEFAULT ADMIN ACCOUNT CREATED ║" ); + Console.WriteLine( "║ Email: admin@werkr.local ║" ); + Console.WriteLine( $"║ Password: {generatedPassword,-41}║" ); + Console.WriteLine( "║ ⚠ Change this password immediately on first login ║" ); + Console.WriteLine( "╚══════════════════════════════════════════════════════╝" ); + Console.WriteLine( ); + + // Optionally write to a file specified by Werkr:WriteAdminPasswordToFile + IConfiguration configuration = services.GetRequiredService( ); + string? passwordFilePath = configuration["Werkr:WriteAdminPasswordToFile"]; + if (!string.IsNullOrWhiteSpace( passwordFilePath )) { + try { + await File.WriteAllTextAsync( passwordFilePath, generatedPassword ); + logger.LogInformation( "Admin password written to file." ); + } catch (Exception ex) { + logger.LogError( ex, "Failed to write admin password to {FilePath}", passwordFilePath ); + } + } + } else { + ILogger logger = services.GetRequiredService( ) + .CreateLogger( "Werkr.Identity.Seeder" ); + foreach (IdentityError error in result.Errors) { + logger.LogError( "Failed to create default admin: {Code} — {Description}", + error.Code, error.Description ); + } + } + } + + } + + private static async Task SeedPermissionsAsync( + RoleManager roleManager, + WerkrIdentityDbContext dbContext ) { + foreach ((DefaultRoles defaultRole, Permission[] permissions) in s_defaultPermissions) { + IdentityRole? role = await roleManager.FindByNameAsync( defaultRole.ToString( ) ); + if (role is null) { + continue; + } + + foreach (Permission permission in permissions) { + bool exists = await dbContext.RolePermissions + .AnyAsync( rp => rp.RoleId == role.Id && rp.Permission == permission ); + + if (!exists) { + _ = dbContext.RolePermissions.Add( new RolePermission { + RoleId = role.Id, + Permission = permission, + } ); + } + } + } + + _ = await dbContext.SaveChangesAsync( ); + } + + private static string GenerateDefaultAdminPassword( ) { + const string Upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const string Lower = "abcdefghijklmnopqrstuvwxyz"; + const string Digits = "0123456789"; + const string Symbols = "!@#$%^&*()-_=+[]{};:,.?"; + const string All = Upper + Lower + Digits + Symbols; + + Span passwordChars = stackalloc char[24]; + passwordChars[0] = Upper[RandomNumberGenerator.GetInt32( Upper.Length )]; + passwordChars[1] = Lower[RandomNumberGenerator.GetInt32( Lower.Length )]; + passwordChars[2] = Digits[RandomNumberGenerator.GetInt32( Digits.Length )]; + passwordChars[3] = Symbols[RandomNumberGenerator.GetInt32( Symbols.Length )]; + + for (int i = 4; i < passwordChars.Length; i++) { + passwordChars[i] = All[RandomNumberGenerator.GetInt32( All.Length )]; + } + + for (int i = passwordChars.Length - 1; i > 0; i--) { + int j = RandomNumberGenerator.GetInt32( i + 1 ); + (passwordChars[i], passwordChars[j]) = (passwordChars[j], passwordChars[i]); + } + + return new string( passwordChars ); + } +} diff --git a/src/Werkr.Server/Identity/JwtTokenService.cs b/src/Werkr.Server/Identity/JwtTokenService.cs new file mode 100644 index 0000000..1dbba42 --- /dev/null +++ b/src/Werkr.Server/Identity/JwtTokenService.cs @@ -0,0 +1,150 @@ +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +using Werkr.Common.Auth; +using Werkr.Data.Identity.Entities; + +namespace Werkr.Server.Identity; + +/// +/// Service for generating short-lived JWT bearer tokens from validated API keys. +/// Uses HMAC-SHA256 symmetric signing. Tokens are 15-minute, non-refreshable. +/// +/// Also generates service tokens for Server→API calls (no API key required) +/// via . +/// +/// +public sealed class JwtTokenService { + private readonly SymmetricSecurityKey _signingKey; + private readonly string _issuer; + private readonly string _audience; + private readonly TimeSpan _tokenLifetime; + private readonly ILogger _logger; + + /// + /// Initializes the JWT token service with signing configuration. + /// + /// Application configuration (reads Jwt:SigningKey, Jwt:Issuer, Jwt:Audience). + /// Logger instance. + public JwtTokenService( IConfiguration configuration, ILogger logger ) { + _logger = logger; + + string signingKey = configuration["Jwt:SigningKey"] + ?? throw new InvalidOperationException( + "JWT signing key is not configured. Set 'Jwt:SigningKey' in appsettings.json or environment variables." ); + + if (signingKey.Length < 32) { + throw new InvalidOperationException( + "JWT signing key must be at least 32 characters (256 bits) for HMAC-SHA256." ); + } + + _signingKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes( signingKey ) ); + _issuer = configuration["Jwt:Issuer"] ?? "werkr-api"; + _audience = configuration["Jwt:Audience"] ?? "werkr"; + _tokenLifetime = TimeSpan.FromMinutes( + int.TryParse( configuration["Jwt:TokenLifetimeMinutes"], out int minutes ) ? minutes : 15 ); + } + + /// + /// Generates a JWT bearer token for the given API key. + /// + /// The validated API key entity. + /// The signed JWT token string. + public string GenerateToken( ApiKey apiKey ) => + GenerateToken( apiKey, [] ); + + /// + /// Generates a JWT bearer token for the given API key with embedded permission claims. + /// + /// The validated API key entity. + /// Pre-resolved permissions for the API key's role. + /// The signed JWT token string. + public string GenerateToken( ApiKey apiKey, IReadOnlyList permissions ) { + List claims = [ + new( ClaimTypes.NameIdentifier, apiKey.CreatedByUserId ), + new( ClaimTypes.Role, apiKey.Role ), + new( WerkrClaimTypes.ApiKeyId, apiKey.Id.ToString( ) ), + new( WerkrClaimTypes.ApiKeyName, apiKey.Name ), + new( JwtRegisteredClaimNames.Jti, Guid.NewGuid( ).ToString( ) ), + ]; + + foreach (Permission permission in permissions) { + claims.Add( new Claim( WerkrClaimTypes.Permission, permission.ToString( ) ) ); + } + + string tokenString = MintToken( claims ); + + if (_logger.IsEnabled( LogLevel.Debug )) { + _logger.LogDebug( "Generated JWT for API key '{Name}' (prefix: {Prefix}), expires in {Lifetime} minutes, permissions: {Permissions}.", + apiKey.Name, apiKey.KeyPrefix, _tokenLifetime.TotalMinutes, + string.Join( ", ", permissions ) ); + } + + return tokenString; + } + + /// + /// Mints a JWT for Server→API service-to-service calls. + /// Uses a built-in service identity with full permissions. + /// No API key required — the Server is the token issuer and trusts itself. + /// + /// The signed JWT token string. + public string GenerateServiceToken( ) { + List claims = [ + new( ClaimTypes.NameIdentifier, "werkr-server" ), + new( ClaimTypes.Role, "Admin" ), + new( WerkrClaimTypes.ApiKeyId, Guid.Empty.ToString( ) ), + new( WerkrClaimTypes.ApiKeyName, "werkr-server-internal" ), + new( JwtRegisteredClaimNames.Jti, Guid.NewGuid( ).ToString( ) ), + ]; + + // Grant all permissions for service-to-service calls + foreach (Permission permission in Enum.GetValues( )) { + claims.Add( new Claim( WerkrClaimTypes.Permission, permission.ToString( ) ) ); + } + + string tokenString = MintToken( claims ); + + if (_logger.IsEnabled( LogLevel.Debug )) { + _logger.LogDebug( "Generated Server service JWT, expires in {Lifetime} minutes.", _tokenLifetime.TotalMinutes ); + } + + return tokenString; + } + + /// + /// Mints a signed JWT from the given claims using . + /// + private string MintToken( List claims ) { + SigningCredentials credentials = new( _signingKey, SecurityAlgorithms.HmacSha256 ); + + SecurityTokenDescriptor descriptor = new( ) { + Issuer = _issuer, + Audience = _audience, + Subject = new ClaimsIdentity( claims ), + Expires = DateTime.UtcNow.Add( _tokenLifetime ), + SigningCredentials = credentials, + }; + + return new JsonWebTokenHandler( ).CreateToken( descriptor ); + } + + /// + /// Gets the token validation parameters for configuring JWT Bearer authentication. + /// + /// The . + public TokenValidationParameters GetValidationParameters( ) { + return new TokenValidationParameters { + ValidateIssuerSigningKey = true, + IssuerSigningKey = _signingKey, + ValidateIssuer = true, + ValidIssuer = _issuer, + ValidateAudience = true, + ValidAudience = _audience, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes( 1 ), + }; + } +} diff --git a/src/Werkr.Server/Identity/WerkrCookieAuthEvents.cs b/src/Werkr.Server/Identity/WerkrCookieAuthEvents.cs new file mode 100644 index 0000000..be4dc43 --- /dev/null +++ b/src/Werkr.Server/Identity/WerkrCookieAuthEvents.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; + +using Werkr.Data.Identity.Entities; + +namespace Werkr.Server.Identity; + +/// +/// Cookie authentication event handlers for Werkr-specific security policies. +/// +public sealed class WerkrCookieAuthEvents : CookieAuthenticationEvents { + /// + public override async Task ValidatePrincipal( CookieValidatePrincipalContext context ) { + if (context.Principal?.Identity?.IsAuthenticated != true) { + return; + } + + UserManager userManager = context.HttpContext.RequestServices + .GetRequiredService>( ); + + WerkrUser? user = await userManager.GetUserAsync( context.Principal ); + if (user is null) { + context.RejectPrincipal( ); + return; + } + + PathString requestPath = context.HttpContext.Request.Path; + + if (!user.Enabled) { + context.RejectPrincipal( ); + await context.HttpContext.SignOutAsync( IdentityConstants.ApplicationScheme ); + return; + } + + if (user.ChangePassword && !IsAllowedPathForPasswordChange( requestPath )) { + context.HttpContext.Response.Redirect( "/account/change-password" ); + context.ShouldRenew = true; + return; + } + + if (user.Requires2FA && !user.TwoFactorEnabled && !IsAllowedPathForMfaEnrollment( requestPath )) { + context.HttpContext.Response.Redirect( "/account/manage/mfa?required=true" ); + context.ShouldRenew = true; + } + } + + private static bool IsAllowedPathForPasswordChange( PathString path ) { + return path.StartsWithSegments( "/account/change-password", StringComparison.OrdinalIgnoreCase ) + || path.StartsWithSegments( "/_blazor", StringComparison.OrdinalIgnoreCase ) + || path.StartsWithSegments( "/account/logout", StringComparison.OrdinalIgnoreCase ) + || path.StartsWithSegments( "/account/access-denied", StringComparison.OrdinalIgnoreCase ) + || IsStaticAsset( path ); + } + + private static bool IsAllowedPathForMfaEnrollment( PathString path ) { + return path.StartsWithSegments( "/account/manage/mfa", StringComparison.OrdinalIgnoreCase ) + || path.StartsWithSegments( "/_blazor", StringComparison.OrdinalIgnoreCase ) + || path.StartsWithSegments( "/account/logout", StringComparison.OrdinalIgnoreCase ) + || path.StartsWithSegments( "/account/access-denied", StringComparison.OrdinalIgnoreCase ) + || path.StartsWithSegments( "/account/change-password", StringComparison.OrdinalIgnoreCase ) + || IsStaticAsset( path ); + } + + private static bool IsStaticAsset( PathString path ) { + return path.StartsWithSegments( "/_framework", StringComparison.OrdinalIgnoreCase ) + || path.StartsWithSegments( "/_content", StringComparison.OrdinalIgnoreCase ) + || path.StartsWithSegments( "/lib", StringComparison.OrdinalIgnoreCase ) + || path.StartsWithSegments( "/css", StringComparison.OrdinalIgnoreCase ) + || path.StartsWithSegments( "/js", StringComparison.OrdinalIgnoreCase ) + || path.StartsWithSegments( "/images", StringComparison.OrdinalIgnoreCase ) || Path.HasExtension( path.Value ); + } +} diff --git a/src/Werkr.Server/Program.cs b/src/Werkr.Server/Program.cs new file mode 100644 index 0000000..10dbe61 --- /dev/null +++ b/src/Werkr.Server/Program.cs @@ -0,0 +1,176 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Serilog; +using Serilog.Settings.Configuration; +using Werkr.Common; +using Werkr.Common.Auth; +using Werkr.Common.Extensions; +using Werkr.Data.Identity; +using Werkr.Data.Identity.Authorization; +using Werkr.Data.Identity.Extensions; +using Werkr.Data.Identity.Services; +using Werkr.Server.Components; +using Werkr.Server.Endpoints; +using Werkr.Server.Identity; +using Werkr.Server.Services; +using Werkr.ServiceDefaults; + +namespace Werkr.Server; + +/// Application entry point for the Werkr Server (Blazor UI). +public class Program { + /// Main entry point. + /// Command-line arguments. + public static async Task Main( string[] args ) { + Log.Logger = new LoggerConfiguration( ) + .WriteTo.Console( ) + .CreateBootstrapLogger( ); + + try { + Log.Information( "Starting Werkr Server..." ); + + string version = System.Reflection.CustomAttributeExtensions + .GetCustomAttribute( + System.Reflection.Assembly.GetEntryAssembly( )! ) + ?.InformationalVersion ?? "unknown"; + Log.Information( "Werkr Server version {Version}", version ); + + WebApplicationBuilder builder = WebApplication.CreateBuilder( args ); + + _ = builder.Configuration.AddWerkrConfigPath( "Server" ); + + // Serilog (ConfigurationReaderOptions required for single-file publish) + ConfigurationReaderOptions readerOptions = new( + typeof( Serilog.ConsoleLoggerConfigurationExtensions ).Assembly, + typeof( Serilog.FileLoggerConfigurationExtensions ).Assembly, + typeof( Serilog.Sinks.OpenTelemetry.OtlpProtocol ).Assembly ); + _ = builder.Host.UseSerilog( ( ctx, lc ) => lc + .ReadFrom.Configuration( ctx.Configuration, readerOptions ) ); + + // Add service defaults & Aspire client integrations. + _ = builder.AddServiceDefaults( ); + + // Data Protection — persist keys so authenticator tokens survive container restarts + DirectoryInfo keysDir = new( Path.Combine( builder.Environment.ContentRootPath, "keys" ) ); + _ = builder.Services.AddDataProtection( ) + .SetApplicationName( "Werkr" ) + .PersistKeysToFileSystem( keysDir ); + + // Identity (uses separate identity database) — provider is configurable via Database:Provider + string connectionString = builder.Configuration.GetConnectionString( "werkridentitydb" ) ?? string.Empty; + DatabaseProvider dbProvider = Enum.TryParse( + builder.Configuration["Database:Provider"], ignoreCase: true, out DatabaseProvider parsed ) + ? parsed : DatabaseProvider.Postgres; + _ = builder.Services.AddWerkrIdentity( dbProvider, connectionString ); + + // Permission system (role-permission mapping, authorization policies) + _ = builder.Services.AddScoped( ); + _ = builder.Services.AddAuthorization( options => options.AddWerkrPermissionPolicies( ) ); + _ = builder.Services.AddScoped( ); + + // JWT token service — Server is the sole JWT issuer (Decision A1) + _ = builder.Services.AddSingleton( sp => { + IConfiguration config = sp.GetRequiredService( ); + ILogger logger = sp.GetRequiredService>( ); + return new JwtTokenService( config, logger ); + } ); + + // API key service — Server owns the identity DB (Decision A1) + _ = builder.Services.AddScoped( ); + + // Configure auth cookie paths + _ = builder.Services.ConfigureApplicationCookie( options => { + options.LoginPath = "/account/login"; + options.AccessDeniedPath = "/account/access-denied"; + options.LogoutPath = "/account/logout"; + options.EventsType = typeof( WerkrCookieAuthEvents ); + options.ExpireTimeSpan = TimeSpan.FromMinutes( 30 ); + options.SlidingExpiration = true; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.Cookie.SameSite = SameSiteMode.Strict; + } ); + + _ = builder.Services.AddScoped( ); + _ = builder.Services.AddHttpContextAccessor( ); + + // Make auth state available to interactive server circuits (not just SSR) + _ = builder.Services.AddCascadingAuthenticationState( ); + + // Add services to the container. + _ = builder.Services.AddRazorComponents( ) + .AddInteractiveServerComponents( options => { + options.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds( 60 ); + options.DetailedErrors = builder.Environment.IsDevelopment( ); + options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes( 3 ); + } ); + + _ = builder.Services.AddOutputCache( ); + + // Server configuration cache — reads config from the DB instead of appsettings + _ = builder.Services.AddSingleton( ); + + // Auth forwarding handler — self-mints JWT for outgoing API requests + _ = builder.Services.AddTransient( ); + + // General-purpose HttpClient for the API service via service discovery + _ = builder.Services.AddHttpClient( "ApiService", client => { + client.BaseAddress = new Uri( "https://api" ); + } ) + .AddHttpMessageHandler( ); + + // Background health monitor — keeps agent DB status in sync with actual reachability + _ = builder.Services.AddHostedService( ); + + WebApplication app = builder.Build( ); + + // Auto-migrate identity DB in development + if (app.Environment.IsDevelopment( )) { + using IServiceScope scope = app.Services.CreateScope( ); + + WerkrIdentityDbContext identityDb = scope.ServiceProvider + .GetRequiredService( ); + await identityDb.Database.MigrateAsync( ); + } + + // Initialize the server config cache from the database + ServerConfigCache configCache = app.Services.GetRequiredService( ); + await configCache.InitializeAsync( ); + + // Seed default roles and admin account + await IdentitySeeder.SeedAsync( app.Services ); + + if (!app.Environment.IsDevelopment( )) { + _ = app.UseExceptionHandler( "/Error", createScopeForErrors: true ); + _ = app.UseHsts( ); + } + + _ = app.UseHttpsRedirection( ); + + // Authentication & Authorization middleware (BEFORE MapRazorComponents) + _ = app.UseAuthentication( ); + _ = app.UseAuthorization( ); + + _ = app.UseAntiforgery( ); + + _ = app.UseOutputCache( ); + + _ = app.MapStaticAssets( ); + + _ = app.MapRazorComponents( ) + .AddInteractiveServerRenderMode( ); + + _ = app.MapDefaultEndpoints( ); + + // Auth endpoints — token exchange and API key management (Decision A1) + _ = app.MapAuthEndpoints( ); + + app.Run( ); + } catch (Exception ex) { + Log.Fatal( ex, "Werkr Server terminated unexpectedly." ); + } finally { + Log.CloseAndFlush( ); + } + } +} diff --git a/src/Werkr.Server/Services/ActionParameterRegistry.cs b/src/Werkr.Server/Services/ActionParameterRegistry.cs new file mode 100644 index 0000000..00ca8c5 --- /dev/null +++ b/src/Werkr.Server/Services/ActionParameterRegistry.cs @@ -0,0 +1,141 @@ + +using System.Collections.Frozen; + +namespace Werkr.Server.Services; +/// Describes the field type rendered in the parameter editor. +public enum FieldType { + /// Single-line text input. + Text, + /// Multi-line text area. + TextArea, + /// Numeric input. + Number, + /// Boolean toggle / checkbox. + Bool, + /// Dropdown select from a fixed set of options. + Select +} + +/// Describes a single form field for an action parameter. +/// JSON property name (PascalCase) written into the parameters blob. +/// Human-friendly label shown in the form. +/// Control type to render. +/// Whether the field must have a value. +/// Default value as a string (bool → "false", int → "0", etc.). +/// Optional placeholder text. +/// For — the allowed option values. +/// Tooltip or small help text shown below the control. +public sealed record FieldDescriptor( + string Name, + string Label, + FieldType Type, + bool Required = false, + string? DefaultValue = null, + string? Placeholder = null, + string[]? Options = null, + string? HelpText = null ); + +/// Describes a single built-in action and its expected parameter fields. +/// Action key string (e.g. "CopyFile") stored in ActionSubType. +/// Human-friendly name shown in the dropdown. +/// Short description of what the action does. +/// Ordered list of form fields. +public sealed record ActionFormDescriptor( + string Key, + string DisplayName, + string Description, + IReadOnlyList Fields ); + +/// +/// Registry of all built-in action form descriptors. +/// Used by the ActionParameterEditor component to render dynamic forms. +/// +public static class ActionParameterRegistry { + /// Encoding values matching the PowerShell Out-File parameter set. + public static readonly string[] Encodings = [ + "ascii", "utf-8", "utf-8-bom", "utf-16", "utf-16BE", + "utf-32", "utf-32BE", "utf-7", "oem" + ]; + + /// PathType values for TestExists (matches Werkr.Common.Models.PathType enum). + private static readonly string[] PathTypes = ["File", "Directory", "Any"]; + + private static readonly ActionFormDescriptor[] AllDescriptors = [ + // ── File operations ────────────────────────────────────────── + new( "CopyFile", "Copy File", "Copy a file or directory to a new location.", [ + new( "Source", "Source Path", FieldType.Text, Required: true, Placeholder: "C:\\source\\file.txt", HelpText: "Supports wildcard patterns." ), + new( "Destination", "Destination Path", FieldType.Text, Required: true, Placeholder: "C:\\dest\\file.txt" ), + new( "Overwrite", "Overwrite", FieldType.Bool, DefaultValue: "false" ), + new( "Recursive", "Recursive", FieldType.Bool, DefaultValue: "false", HelpText: "Copy directories recursively." ), + ] ), + + new( "MoveFile", "Move File", "Move a file or directory to a new location.", [ + new( "Source", "Source Path", FieldType.Text, Required: true, Placeholder: "C:\\source\\file.txt", HelpText: "Supports wildcard patterns." ), + new( "Destination", "Destination Path", FieldType.Text, Required: true, Placeholder: "C:\\dest\\file.txt" ), + new( "Overwrite", "Overwrite", FieldType.Bool, DefaultValue: "false" ), + ] ), + + new( "RenameFile", "Rename File", "Rename a file or directory.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\item" ), + new( "NewName", "New Name", FieldType.Text, Required: true, Placeholder: "new-name.txt", HelpText: "Just the name, not a full path." ), + new( "Overwrite", "Overwrite", FieldType.Bool, DefaultValue: "false" ), + ] ), + + new( "DeleteFile", "Delete File", "Delete a file or directory.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\item" ), + new( "Recursive", "Recursive", FieldType.Bool, DefaultValue: "false" ), + new( "Force", "Force", FieldType.Bool, DefaultValue: "false", HelpText: "Remove read-only attributes before deletion." ), + ] ), + + new( "CreateFile", "Create File", "Create a new file with optional content.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\file.txt" ), + new( "Content", "Content", FieldType.TextArea, Placeholder: "File content…" ), + new( "Overwrite", "Overwrite", FieldType.Bool, DefaultValue: "false" ), + new( "Encoding", "Encoding", FieldType.Select, DefaultValue: "utf-8", Options: Encodings ), + new( "CreateParentDirectories", "Create Parent Dirs", FieldType.Bool, DefaultValue: "true" ), + ] ), + + new( "CreateDirectory", "Create Directory", "Create one or more directories.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\directory" ), + ] ), + + new( "TestExists", "Test Exists", "Check if a path exists.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\check" ), + new( "Type", "Path Type", FieldType.Select, DefaultValue: "Any", Options: PathTypes, HelpText: "File, Directory, or Any." ), + ] ), + + // ── Content operations ─────────────────────────────────────── + new( "ClearContent", "Clear Content", "Truncate a file to zero bytes.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\file.txt" ), + ] ), + + new( "WriteContent", "Write Content", "Write or append text to a file.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\file.txt" ), + new( "Content", "Content", FieldType.TextArea, Required: true, Placeholder: "Text to write…" ), + new( "Append", "Append", FieldType.Bool, DefaultValue: "false", HelpText: "Append instead of overwriting." ), + new( "Encoding", "Encoding", FieldType.Select, DefaultValue: "utf-8", Options: Encodings ), + ] ), + + // ── Process operations ─────────────────────────────────────── + new( "StartProcess", "Start Process", "Launch a process.", [ + new( "FileName", "File Name", FieldType.Text, Required: true, Placeholder: "notepad.exe" ), + new( "Arguments", "Arguments", FieldType.Text, Placeholder: "--flag value" ), + new( "WorkingDirectory", "Working Directory", FieldType.Text, Placeholder: "C:\\work" ), + new( "WaitForExit", "Wait for Exit", FieldType.Bool, DefaultValue: "false" ), + new( "TimeoutMs", "Timeout (ms)", FieldType.Number, HelpText: "Only used when Wait for Exit is true." ), + ] ), + + new( "StopProcess", "Stop Process", "Stop a running process.", [ + new( "ProcessName", "Process Name", FieldType.Text, Required: true, Placeholder: "notepad" ), + new( "ProcessId", "Process ID", FieldType.Number, HelpText: "Optional — when set only this PID is stopped." ), + new( "Force", "Force", FieldType.Bool, DefaultValue: "false", HelpText: "Forcefully terminate the process." ), + ] ), + ]; + + /// Fast lookup by action key (case-insensitive). + public static readonly FrozenDictionary Actions = + AllDescriptors.ToFrozenDictionary( d => d.Key, StringComparer.OrdinalIgnoreCase ); + + /// Ordered list of all descriptors for populating dropdowns. + public static IReadOnlyList All => AllDescriptors; +} diff --git a/src/Werkr.Server/Services/AgentHealthMonitorService.cs b/src/Werkr.Server/Services/AgentHealthMonitorService.cs new file mode 100644 index 0000000..e8a3e95 --- /dev/null +++ b/src/Werkr.Server/Services/AgentHealthMonitorService.cs @@ -0,0 +1,86 @@ +using Werkr.Common.Models; + +namespace Werkr.Server.Services; + +/// +/// Background service that periodically checks agent health via the API's +/// /api/agents/health endpoint and updates each agent's persisted status +/// via PUT /api/agents/{id}/status. This keeps the DB status in sync +/// with actual reachability so all pages (not just the Dashboard) see accurate data. +/// +public sealed class AgentHealthMonitorService : BackgroundService { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ServerConfigCache _configCache; + private readonly ILogger _logger; + + /// Initializes the health monitor. + public AgentHealthMonitorService( + IHttpClientFactory httpClientFactory, + ServerConfigCache configCache, + ILogger logger ) { + _httpClientFactory = httpClientFactory; + _configCache = configCache; + _logger = logger; + } + + /// + protected override async Task ExecuteAsync( CancellationToken stoppingToken ) { + // Wait briefly for the app to finish starting and for the system API key to be seeded. + await Task.Delay( TimeSpan.FromSeconds( 15 ), stoppingToken ); + + int intervalSeconds = _configCache.PollingIntervalSeconds; + using PeriodicTimer timer = new( TimeSpan.FromSeconds( intervalSeconds ) ); + + _logger.LogInformation( "AgentHealthMonitorService started." ); + + while (await timer.WaitForNextTickAsync( stoppingToken )) { + try { + await PollAndUpdateAsync( stoppingToken ); + } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { + break; + } catch (Exception ex) { + _logger.LogWarning( ex, "Health monitor poll failed." ); + } + } + } + + private async Task PollAndUpdateAsync( CancellationToken ct ) { + HttpClient client = _httpClientFactory.CreateClient( "ApiService" ); + + // Get live health from the API (which does real gRPC checks) + List? healthResults = await client.GetFromJsonAsync>( + "/api/agents/health", ct ); + + if (healthResults is null || healthResults.Count == 0) { + return; + } + + foreach (AgentHealthDto health in healthResults) { + // Map the health check result to a ConnectionStatus value + string newStatus = health.Status switch { + "Connected" => "Connected", + "Unreachable" => "Disconnected", + "Error" => "Error", + _ => health.Status // pass through Disconnected, Revoked, etc. + }; + + // Only update if the status maps to a valid ConnectionStatus enum value + if (!Enum.TryParse( newStatus, ignoreCase: true, out _ )) { + continue; + } + + try { + using HttpResponseMessage response = await client.PutAsJsonAsync( + $"/api/agents/{health.AgentId}/status", + new UpdateAgentStatusRequest( newStatus ), + ct ); + + if (!response.IsSuccessStatusCode) { + _logger.LogDebug( "Failed to update agent status." ); + } + } catch (Exception ex) { + _logger.LogDebug( ex, "Failed to update agent status." ); + } + } + } +} diff --git a/src/Werkr.Server/Services/ServerConfigCache.cs b/src/Werkr.Server/Services/ServerConfigCache.cs new file mode 100644 index 0000000..f13f36a --- /dev/null +++ b/src/Werkr.Server/Services/ServerConfigCache.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; + +using Werkr.Data.Identity; +using Werkr.Data.Identity.Entities; + +namespace Werkr.Server.Services; + +/// +/// Singleton cache of the row stored in the database. +/// Provides fast, synchronous property access for UI polling intervals and server identity. +/// +/// The cache is loaded once during startup via (called from +/// Program.cs before the identity seeder runs) and can be refreshed on demand. +/// +/// +public sealed class ServerConfigCache { + private readonly IServiceProvider _services; + private readonly ILogger _logger; + private volatile ConfigurationSettings _config = new( ); + + /// Creates a new instance backed by the application service provider. + public ServerConfigCache( IServiceProvider services, ILogger logger ) { + _services = services; + _logger = logger; + } + + // ── Synchronous property accessors (hot path) ──────────────────── + + /// Dashboard / list auto-refresh interval in seconds (minimum 10). + public int PollingIntervalSeconds => Math.Max( _config.PollingIntervalSeconds, 10 ); + + /// Run-detail auto-refresh interval in seconds (minimum 5). + public int RunDetailPollingIntervalSeconds => Math.Max( _config.RunDetailPollingIntervalSeconds, 5 ); + + /// Display name for the Blazor UI header. + public string ServerName => _config.ServerName; + + /// Whether new agent registrations are accepted. + public bool AllowRegistration => _config.AllowRegistration; + + // ── Lifecycle ──────────────────────────────────────────────────── + + /// + /// Load config from the database, creating a default row if none exists. + /// Called once from Program.cs after DB migration and before the identity seeder. + /// + public async Task InitializeAsync( CancellationToken ct = default ) { + using IServiceScope scope = _services.CreateScope( ); + WerkrIdentityDbContext db = scope.ServiceProvider.GetRequiredService( ); + + ConfigurationSettings? config = await db.ConfigurationSettings.FirstOrDefaultAsync( ct ); + if (config is null) { + config = new ConfigurationSettings { + Created = DateTime.UtcNow, + LastUpdated = DateTime.UtcNow, + }; + _ = db.ConfigurationSettings.Add( config ); + _ = await db.SaveChangesAsync( ct ); + _logger.LogInformation( "Seeded default server configuration." ); + } + + _config = config; + } + + /// Reload the cached configuration from the database. + public async Task RefreshAsync( CancellationToken ct = default ) { + using IServiceScope scope = _services.CreateScope( ); + WerkrIdentityDbContext db = scope.ServiceProvider.GetRequiredService( ); + _config = await db.ConfigurationSettings.FirstOrDefaultAsync( ct ) ?? new( ); + } +} diff --git a/src/Werkr.Server/Utilities/SchedulePreviewCalculator.cs b/src/Werkr.Server/Utilities/SchedulePreviewCalculator.cs new file mode 100644 index 0000000..f8a44bd --- /dev/null +++ b/src/Werkr.Server/Utilities/SchedulePreviewCalculator.cs @@ -0,0 +1,245 @@ +using Werkr.Common.Models; + +namespace Werkr.Server.Utilities; + +/// +/// Client-side occurrence preview calculator for schedule forms. +/// Computes the next N occurrences from a +/// without requiring a server round-trip. +/// +internal static class SchedulePreviewCalculator { + /// + /// Calculate the next occurrences for a schedule. + /// + /// The schedule definition. + /// Maximum number of occurrences to return. + /// A list of UTC occurrence date-times. + internal static List Calculate( ScheduleCreateRequest request, int count = 10 ) { + List results = []; + if (count <= 0) { + return results; + } + + TimeZoneInfo tz = GetTimeZone( request.StartDateTime.TimeZoneId ); + DateTime startLocal = request.StartDateTime.Date.ToDateTime( request.StartDateTime.Time ); + DateTime startUtc = TimeZoneInfo.ConvertTimeToUtc( startLocal, tz ); + + DateTime? expirationUtc = null; + if (request.Expiration is not null) { + DateTime expLocal = request.Expiration.Date.ToDateTime( request.Expiration.Time ); + TimeZoneInfo expTz = GetTimeZone( request.Expiration.TimeZoneId ); + expirationUtc = TimeZoneInfo.ConvertTimeToUtc( expLocal, expTz ); + } + + // Window limit — 1 year from now max + DateTime windowEnd = DateTime.UtcNow.AddDays( 365 ); + if (expirationUtc.HasValue && expirationUtc.Value < windowEnd) { + windowEnd = expirationUtc.Value; + } + + DateTime cursor = startUtc; + + // One-time schedule + if (request.DailyRecurrence is null && request.WeeklyRecurrence is null && request.MonthlyRecurrence is null) { + if (cursor <= windowEnd) { + results.Add( cursor ); + AddRepeatOccurrences( results, cursor, request.RepeatOptions, windowEnd, count ); + } + return results; + } + + while (cursor <= windowEnd && results.Count < count) { + if (cursor >= DateTime.UtcNow) { + results.Add( cursor ); + AddRepeatOccurrences( results, cursor, request.RepeatOptions, windowEnd, count ); + if (results.Count >= count) { + break; + } + } + + cursor = AdvanceCursor( cursor, tz, request ); + if (cursor > windowEnd) { + break; + } + } + + return results; + } + + /// + /// Adds intra-cycle repeat occurrences within the RepeatDurationMinutes window. + /// + private static void AddRepeatOccurrences( + List results, DateTime baseOccurrence, + RepeatOptionsDto? repeatOptions, DateTime windowEnd, int maxCount ) { + if (repeatOptions is null || repeatOptions.RepeatIntervalMinutes <= 0) { + return; + } + + int durationMinutes = repeatOptions.RepeatDurationMinutes; + // -1 means indefinite — cap at 24 hours for preview safety + if (durationMinutes < 0) { + durationMinutes = 1440; + } + + if (durationMinutes <= 0) { + return; + } + + DateTime repeatEnd = baseOccurrence.AddMinutes( durationMinutes ); + if (repeatEnd > windowEnd) { + repeatEnd = windowEnd; + } + + DateTime repeatCursor = baseOccurrence.AddMinutes( repeatOptions.RepeatIntervalMinutes ); + while (repeatCursor <= repeatEnd && results.Count < maxCount) { + results.Add( repeatCursor ); + repeatCursor = repeatCursor.AddMinutes( repeatOptions.RepeatIntervalMinutes ); + } + } + + private static DateTime AdvanceCursor( DateTime cursorUtc, TimeZoneInfo tz, ScheduleCreateRequest req ) { + DateTime local = TimeZoneInfo.ConvertTimeFromUtc( cursorUtc, tz ); + + if (req.DailyRecurrence is not null) { + local = local.AddDays( Math.Max( 1, req.DailyRecurrence.DayInterval ) ); + } else if (req.WeeklyRecurrence is not null) { + local = AdvanceWeekly( local, req.WeeklyRecurrence ); + } else if (req.MonthlyRecurrence is not null) { + local = AdvanceMonthly( local, req.MonthlyRecurrence ); + } else { + // Should not reach here — treat as one-time, push past window + return DateTime.MaxValue; + } + + return TimeZoneInfo.ConvertTimeToUtc( local, tz ); + } + + private static DateTime AdvanceWeekly( DateTime local, WeeklyRecurrenceDto weekly ) { + // Try next day-of-week in current week, otherwise jump to next N-week cycle + DateTime next = local.AddDays( 1 ); + for (int i = 0; i < 7; i++) { + int flag = DayOfWeekToFlag( next.DayOfWeek ); + if ((weekly.DaysOfWeek & flag) != 0) { + return next; + } + next = next.AddDays( 1 ); + } + + // Jump by interval weeks from the start of current week + int interval = Math.Max( 1, weekly.WeekInterval ); + int daysToStartOfWeek = ( local.DayOfWeek == 0 ) ? 0 : (int) local.DayOfWeek; + DateTime weekStart = local.AddDays( -daysToStartOfWeek ).AddDays( interval * 7 ); + + for (int i = 0; i < 7; i++) { + int flag = DayOfWeekToFlag( weekStart.AddDays( i ).DayOfWeek ); + if ((weekly.DaysOfWeek & flag) != 0) { + return weekStart.AddDays( i ); + } + } + + return local.AddDays( interval * 7 ); + } + + private static DateTime AdvanceMonthly( DateTime local, MonthlyRecurrenceDto monthly ) { + // Week+Day mode: find Nth weekday of matching month + if (monthly.WeekNumber is not null && monthly.DaysOfWeek is not null) { + return AdvanceMonthlyWeekAndDay( local, monthly ); + } + + // DayNumbers mode: advance to next matching month + day + DateTime next = local.AddMonths( 1 ); + for (int attempt = 0; attempt < 24; attempt++) { + int monthFlag = 1 << ( next.Month - 1 ); + if ((monthly.MonthsOfYear & monthFlag) != 0) { + if (monthly.DayNumbers is { Length: > 0 }) { + foreach (int rawDay in monthly.DayNumbers.Order( )) { + int day = ResolveDayNumber( rawDay, next.Year, next.Month ); + if (day >= 1 && day <= DateTime.DaysInMonth( next.Year, next.Month )) { + return new DateTime( next.Year, next.Month, day, next.Hour, next.Minute, next.Second, next.Kind ); + } + } + } else { + return new DateTime( next.Year, next.Month, 1, next.Hour, next.Minute, next.Second, next.Kind ); + } + } + next = next.AddMonths( 1 ); + } + + return local.AddYears( 2 ); // fallback + } + + private static DateTime AdvanceMonthlyWeekAndDay( DateTime local, MonthlyRecurrenceDto monthly ) { + DateTime next = local.AddMonths( 1 ); + for (int attempt = 0; attempt < 24; attempt++) { + int monthFlag = 1 << ( next.Month - 1 ); + if ((monthly.MonthsOfYear & monthFlag) != 0) { + DateTime? result = FindWeekAndDayInMonth( + next.Year, next.Month, next.Hour, next.Minute, next.Second, + monthly.WeekNumber!.Value, monthly.DaysOfWeek!.Value, next.Kind ); + if (result.HasValue) { + return result.Value; + } + } + next = next.AddMonths( 1 ); + } + return local.AddYears( 2 ); // fallback + } + + /// + /// Finds the first occurrence matching the WeekNumber + DaysOfWeek pattern in a given month. + /// + private static DateTime? FindWeekAndDayInMonth( + int year, int month, int hour, int minute, int second, + int weekNumberFlags, int daysOfWeekFlags, DateTimeKind kind ) { + int daysInMonth = DateTime.DaysInMonth( year, month ); + _ = new DateTime( year, month, 1 ); + // Build a list of (weekNumber, dayOfWeek, dayOfMonth) for each day + // Week 1 = days 1–7, Week 2 = days 8–14, etc. + for (int day = 1; day <= daysInMonth; day++) { + DateTime d = new( year, month, day ); + int weekNum = (( day - 1 ) / 7) + 1; // 1-based week number + int weekFlag = 1 << ( weekNum - 1 ); + int dayFlag = DayOfWeekToFlag( d.DayOfWeek ); + + if ((weekNumberFlags & weekFlag) != 0 && (daysOfWeekFlags & dayFlag) != 0) { + return new DateTime( year, month, day, hour, minute, second, kind ); + } + } + return null; + } + + /// + /// Resolves a day number, handling negative values (count from end of month). + /// + private static int ResolveDayNumber( int rawDay, int year, int month ) { + if (rawDay > 0) { + return rawDay; + } + + if (rawDay < 0) { + return DateTime.DaysInMonth( year, month ) + rawDay + 1; + } + + return 0; // 0 is invalid + } + + private static int DayOfWeekToFlag( DayOfWeek day ) => day switch { + DayOfWeek.Sunday => 1, + DayOfWeek.Monday => 2, + DayOfWeek.Tuesday => 4, + DayOfWeek.Wednesday => 8, + DayOfWeek.Thursday => 16, + DayOfWeek.Friday => 32, + DayOfWeek.Saturday => 64, + _ => 0 + }; + + private static TimeZoneInfo GetTimeZone( string timeZoneId ) { + try { + return TimeZoneInfo.FindSystemTimeZoneById( timeZoneId ); + } catch { + return TimeZoneInfo.Utc; + } + } +} diff --git a/src/Werkr.Server/Werkr.Server.csproj b/src/Werkr.Server/Werkr.Server.csproj new file mode 100644 index 0000000..3c378ed --- /dev/null +++ b/src/Werkr.Server/Werkr.Server.csproj @@ -0,0 +1,24 @@ + + + + Werkr.Server + Werkr.Server + + + + + + + + + + + + + + + + + + + diff --git a/src/Werkr.Server/appsettings.Development.json b/src/Werkr.Server/appsettings.Development.json new file mode 100644 index 0000000..a41b13e --- /dev/null +++ b/src/Werkr.Server/appsettings.Development.json @@ -0,0 +1,17 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.AspNetCore": "Warning", + "System": "Information" + } + } + }, + "Jwt": { + "SigningKey": "werkr-dev-signing-key-do-not-use-in-production-min32chars!", + "Issuer": "werkr-api", + "Audience": "werkr" + } +} diff --git a/src/Werkr.Server/appsettings.json b/src/Werkr.Server/appsettings.json new file mode 100644 index 0000000..4054b72 --- /dev/null +++ b/src/Werkr.Server/appsettings.json @@ -0,0 +1,29 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "File", + "Args": { + "path": "logs/log-.txt", + "rollingInterval": "Day" + } + }, + { "Name": "OpenTelemetry" } + ], + "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ] + }, + "AllowedHosts": "*", + "Ui": { + "PollingIntervalSeconds": 30, + "RunDetailPollingIntervalSeconds": 15 + } +} diff --git a/src/Werkr.Server/packages.lock.json b/src/Werkr.Server/packages.lock.json new file mode 100644 index 0000000..081bf7a --- /dev/null +++ b/src/Werkr.Server/packages.lock.json @@ -0,0 +1,512 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.78.0, )", + "resolved": "2.78.0", + "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" + }, + "Microsoft.AspNetCore.App.Internal.Assets": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mr3Zn+ht8lijYvlMIasftw9opU9hsLKDdnOgQMmYI3RjWPJLOF9l8+YHDseRkTs97wOrULmJgo/NDCmzL/EGDg==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Direct", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "QRCoder": { + "type": "Direct", + "requested": "[1.7.0, )", + "resolved": "1.7.0", + "contentHash": "6R3hQkayihGIDjp3F1nLRDBWG+nqahGyOY2+fH4Rll16Vad67oaUUfHkOiMWKiJFnGh+PIGDfUos+0R9m54O1g==", + "dependencies": { + "System.Drawing.Common": "6.0.0" + } + }, + "Serilog.AspNetCore": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "Direct", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Sinks.OpenTelemetry": { + "type": "Direct", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", + "dependencies": { + "Google.Protobuf": "3.30.1", + "Grpc.Net.Client": "2.70.0", + "Serilog": "4.2.0" + } + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.70.0", + "contentHash": "66UotvWcSIq41oiQhLWcQACyKPM4umxXNiht5DQTLZJfNwEswWOcS7Z0xIEHyNIBE7ZpjotH22bEjTkvhPxmVw==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.70.0", + "contentHash": "rBdEUMyCwa+iB8mqC6JKyPbj3SBHHkReJj/yy/XKJI63GcG6w9DJMMGTVcYHqq4Ci2W4m0HT4jt2pFfFscar8g==", + "dependencies": { + "Grpc.Core.Api": "2.70.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==" + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==" + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==" + }, + "Microsoft.Extensions.FileProviders.Embedded": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "kw/xPl7m4Gv6bqx2ojihTtWiN2K2AklyMIrvncuSi2MOdwu0oMKoyh0G3p2Brt7m43Q9ER0IaA2G4EGjfgDh/w==" + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", + "dependencies": { + "Microsoft.Extensions.Telemetry": "10.3.0" + } + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.ServiceDiscovery.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==" + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==" + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", + "dependencies": { + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", + "dependencies": { + "OpenTelemetry.Api": "1.15.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "6.0.0" + } + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "werkr.data.identity": { + "type": "Project", + "dependencies": { + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.AspNetCore.Identity.UI": "[10.0.3, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )" + } + }, + "werkr.servicedefaults": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.70.0", + "contentHash": "xNv0FFCVJa5S1beUtye82WFCxKThuE1jbN8DO1x1Rj8VSIWXLBUmfSID5a1fGzsU2R/EMfwPoWclJ2RMfQuGXw==", + "dependencies": { + "Grpc.Net.Common": "2.70.0" + } + }, + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "6SEGWi35DZ9syBqCT8v5vEkm9tWUayWxVkHWLwW2FdyXSwS0zzEpIzGPLVQGeug3VU8d+hK/PFxFwwZnblv/zA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "10.0.3" + } + }, + "Microsoft.AspNetCore.Identity.UI": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xhxrP7QcUuyA2FcZsbvdHSqTauPseNrXzhFUYaRj+Elz1nxJceKbW+COc1P9QbpKeZDh9aTDSldHbz3AnMWOqg==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Embedded": "10.0.3" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.3.0", + "Microsoft.Extensions.Resilience": "10.3.0" + } + }, + "Microsoft.Extensions.ServiceDiscovery": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", + "dependencies": { + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + } + } + } +} \ No newline at end of file diff --git a/src/Werkr.Server/wwwroot/app.css b/src/Werkr.Server/wwwroot/app.css new file mode 100644 index 0000000..6d43780 --- /dev/null +++ b/src/Werkr.Server/wwwroot/app.css @@ -0,0 +1,96 @@ +/* Cascadia Mono Nerd Font — terminal / console use */ +@font-face { + font-family: 'CascadiaMonoNF'; + src: url('fonts/CascadiaMonoNF.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +/* ── Operator Console ───────────────────────────────────── */ +.console-page { + display: flex; + flex-direction: column; + height: calc(100vh - 7rem); /* fill viewport minus top-bar + footer */ + padding: 0; +} + +.console-output { + flex: 1 1 auto; + min-height: 200px; + overflow-y: auto; + background: var(--grey-950, #1e1e1e); + color: var(--grey-400, #dadada); + font-family: 'CascadiaMonoNF', 'Cascadia Mono', 'Consolas', 'Courier New', monospace; + font-size: 0.85rem; + line-height: 1.4; + padding: 0.75rem 1rem; + border-radius: 0.375rem; + border: 1px solid var(--grey-800, #333); +} + +.console-input { + font-family: 'CascadiaMonoNF', 'Cascadia Mono', 'Consolas', 'Courier New', monospace; + font-size: 0.9rem; +} + +.console-output .text-muted { + color: var(--grey-600, #888) !important; +} + +a, .btn-link { + color: var(--link-color, #006bb7); +} + +.btn-primary { + color: #fff; + background-color: var(--btn-primary-bg, #1b6ec2); + border-color: var(--btn-primary-border, #1861ac); +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem var(--focus-ring-color, #258cfb); +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50050; +} + +.validation-message { + color: #e50050; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} diff --git a/src/Werkr.Server/wwwroot/css/theme.css b/src/Werkr.Server/wwwroot/css/theme.css new file mode 100644 index 0000000..cb39e92 --- /dev/null +++ b/src/Werkr.Server/wwwroot/css/theme.css @@ -0,0 +1,296 @@ +/* ══════════════════════════════════════════════════════════════ + Werkr Theme — VS Code-inspired grey palette with dark/light modes + ══════════════════════════════════════════════════════════════ */ + +:root { + /* ── Grey scale ── */ + --grey-950: #1e1e1e; + --grey-900: #2d2d30; + --grey-800: #333337; + --grey-700: #5e5e5e; + --grey-600: #777; + --grey-500: #b6b6b6; + --grey-400: #dadada; + --grey-300: #e7e7e7; + --grey-200: #f0f0f0; + --grey-100: #fafafa; + --grey-50: #e1e1e1; + + /* ── Semantic base colors ── */ + --body-bg-light: #d2d2cf; + --body-subnav-light:#ccccc8; + + /* ── Accent palette ── */ + --blue-700: #06357a; + --blue-500: #0a53be; + --blue-300: #86b7fe; + --blue-200: #bacbe6; + --blue-100: #ccd5dc; + --cyan: #0dcaf0; + --green: #198754; + --yellow: #ffd042; + --red: #dc3545; +} + +/* ── Dark Theme (DEFAULT) ─────────────────────────────────── */ +body.dark-theme { + --body-bg-dark: var(--grey-950); + --body-bg: var(--grey-900); + --body-bg-subnav: var(--grey-800); + --body-content-color: var(--blue-100); + --sidebar-bg: var(--grey-950); + --sidebar-text: var(--grey-400); + --sidebar-active-bg: rgba(255,255,255,0.12); + --sidebar-hover-bg: rgba(255,255,255,0.07); + --topbar-bg: var(--grey-800); + --topbar-border: var(--grey-700); + --card-bg: var(--grey-800); + --card-border: var(--grey-700); + --table-stripe-bg: rgba(255,255,255,0.03); + --input-bg: var(--grey-800); + --input-border: var(--grey-600); + --input-text: var(--grey-300); + --link-color: var(--blue-300); + --btn-primary-bg: var(--blue-500); + --btn-primary-border: #1861ac; + --focus-ring-color: #258cfb; + --text-muted-color: var(--grey-600); + --badge-bg: var(--grey-700); + + background-color: var(--body-bg); + color: var(--body-content-color); +} + +/* ── Light Theme ──────────────────────────────────────────── */ +body.light-theme { + --body-bg-dark: var(--grey-50); + --body-bg: var(--body-bg-light); + --body-bg-subnav: var(--body-subnav-light); + --body-content-color: var(--grey-950); + --sidebar-bg: var(--grey-50); + --sidebar-text: var(--grey-700); + --sidebar-active-bg: rgba(0,0,0,0.10); + --sidebar-hover-bg: rgba(0,0,0,0.05); + --topbar-bg: var(--grey-200); + --topbar-border: var(--grey-400); + --card-bg: var(--grey-100); + --card-border: var(--grey-300); + --table-stripe-bg: rgba(0,0,0,0.03); + --input-bg: var(--grey-100); + --input-border: var(--grey-400); + --input-text: var(--grey-900); + --link-color: var(--blue-700); + --btn-primary-bg: var(--blue-500); + --btn-primary-border: #1861ac; + --focus-ring-color: #258cfb; + --text-muted-color: var(--grey-600); + --badge-bg: var(--grey-400); + + background-color: var(--body-bg); + color: var(--body-content-color); +} + +/* ── Bootstrap overrides via CSS variables ────────────────── */ +body.dark-theme, +body.light-theme { + --bs-body-bg: var(--body-bg); + --bs-body-color: var(--body-content-color); + --bs-link-color: var(--link-color); + --bs-link-hover-color: var(--link-color); + --bs-border-color: var(--card-border); +} + +body.dark-theme .card, +body.light-theme .card { + --bs-card-bg: var(--card-bg); + --bs-card-border-color: var(--card-border); + --bs-card-color: var(--body-content-color); + --bs-card-cap-bg: var(--card-bg); +} + +body.dark-theme .table, +body.light-theme .table { + --bs-table-bg: transparent; + --bs-table-color: var(--body-content-color); + --bs-table-striped-bg: var(--table-stripe-bg); + --bs-table-border-color: var(--card-border); + --bs-table-hover-bg: var(--sidebar-hover-bg); +} + +body.dark-theme .form-control, +body.dark-theme .form-select, +body.light-theme .form-control, +body.light-theme .form-select { + background-color: var(--input-bg); + border-color: var(--input-border); + color: var(--input-text); +} + +body.dark-theme .form-control:focus, +body.dark-theme .form-select:focus, +body.light-theme .form-control:focus, +body.light-theme .form-select:focus { + background-color: var(--input-bg); + color: var(--input-text); + border-color: var(--focus-ring-color); +} + +body.dark-theme .form-control::placeholder, +body.light-theme .form-control::placeholder { + color: var(--text-muted-color); +} + +body.dark-theme .dropdown-menu, +body.light-theme .dropdown-menu { + background-color: var(--card-bg); + border-color: var(--card-border); + color: var(--body-content-color); +} + +body.dark-theme .dropdown-item, +body.light-theme .dropdown-item { + color: var(--body-content-color); +} + +body.dark-theme .dropdown-item:hover, +body.dark-theme .dropdown-item:focus, +body.light-theme .dropdown-item:hover, +body.light-theme .dropdown-item:focus { + background-color: var(--sidebar-hover-bg); + color: var(--body-content-color); +} + +body.dark-theme .modal-content, +body.light-theme .modal-content { + background-color: var(--card-bg); + border-color: var(--card-border); + color: var(--body-content-color); +} + +body.dark-theme .alert-info { + --bs-alert-bg: #04414d; + --bs-alert-color: var(--blue-100); + --bs-alert-border-color: #065a6b; +} + +body.dark-theme .alert-danger { + --bs-alert-bg: #6a1a21; + --bs-alert-color: #f5c2c7; + --bs-alert-border-color: #842029; +} + +body.dark-theme .alert-warning { + --bs-alert-bg: #523e02; + --bs-alert-color: #e6dbb9; + --bs-alert-border-color: #6d5303; +} + +body.dark-theme .alert-success { + --bs-alert-bg: #0c4128; + --bs-alert-color: #badbcc; + --bs-alert-border-color: #13653f; +} + +body.dark-theme .text-muted { + color: var(--text-muted-color) !important; +} + +body.dark-theme code { + color: var(--cyan); +} + +body.dark-theme .form-check-input { + background-color: var(--input-bg); + border-color: var(--input-border); +} + +body.dark-theme .form-check-input:checked { + background-color: var(--btn-primary-bg); + border-color: var(--btn-primary-bg); +} + +body.dark-theme .btn-outline-primary { + --bs-btn-color: var(--blue-300); + --bs-btn-border-color: var(--blue-300); + --bs-btn-hover-bg: var(--blue-500); + --bs-btn-hover-border-color: var(--blue-500); + --bs-btn-hover-color: #fff; +} + +body.dark-theme .btn-outline-secondary { + --bs-btn-color: var(--grey-400); + --bs-btn-border-color: var(--grey-600); + --bs-btn-hover-bg: var(--grey-700); + --bs-btn-hover-border-color: var(--grey-600); + --bs-btn-hover-color: #fff; +} + +body.dark-theme .btn-outline-danger { + --bs-btn-color: #f5c2c7; + --bs-btn-border-color: var(--red); + --bs-btn-hover-bg: var(--red); + --bs-btn-hover-border-color: var(--red); + --bs-btn-hover-color: #fff; +} + +body.dark-theme .table-dark { + --bs-table-bg: var(--grey-800); + --bs-table-color: var(--grey-300); + --bs-table-border-color: var(--grey-700); +} + +body.dark-theme .badge.bg-secondary { + background-color: var(--grey-700) !important; +} + +/* ── Theme toggle switch ──────────────────────────────────── */ +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + user-select: none; +} + +.theme-toggle .icon { + font-size: 0.9rem; + line-height: 1; +} + +.theme-toggle .switch { + position: relative; + display: inline-block; + width: 36px; + height: 18px; +} + +.theme-toggle .switch input { + opacity: 0; + width: 0; + height: 0; +} + +.theme-toggle .slider { + position: absolute; + cursor: pointer; + top: 0; left: 0; right: 0; bottom: 0; + background-color: var(--green); + transition: .3s; + border-radius: 18px; +} + +.theme-toggle .slider::before { + content: ""; + position: absolute; + height: 12px; + width: 12px; + left: 3px; + bottom: 3px; + background-color: white; + transition: .3s; + border-radius: 50%; +} + +.theme-toggle .switch input:checked + .slider::before { + transform: translateX(18px); +} diff --git a/src/Werkr.Server/wwwroot/favicon.png b/src/Werkr.Server/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 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

D=NYr`5_ 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

n*)-kT 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≪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 z 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⁡4k8;9AkQCf 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

~A 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

R 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≥*13(x+3Nkg0!az6gfy! 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 literal 0 HcmV?d00001 diff --git a/src/Werkr.Server/wwwroot/js/toggle-theme.js b/src/Werkr.Server/wwwroot/js/toggle-theme.js new file mode 100644 index 0000000..9d31693 --- /dev/null +++ b/src/Werkr.Server/wwwroot/js/toggle-theme.js @@ -0,0 +1,43 @@ +// ── Werkr Theme Toggle ────────────────────────────────────── +// Dark theme is the default. Persists to localStorage. +// The checkbox lives in the statically-rendered NavMenu, so it is +// already in the DOM by the time this script runs (loaded at end of body). + +(function () { + const b = document.body; + if (!b) return; + + function applyTheme(dark) { + b.classList.toggle("dark-theme", dark); + b.classList.toggle("light-theme", !dark); + } + + // Read stored preference (default: dark) + const stored = window.localStorage?.getItem("theme"); + const isDark = !stored || stored === "dark-theme"; + applyTheme(isDark); + + // Bind the toggle checkbox + function bindToggle(sw) { + if (!sw) return; + sw.checked = isDark; + sw.addEventListener("change", function () { + const dark = this.checked; + applyTheme(dark); + localStorage?.setItem("theme", dark ? "dark-theme" : "light-theme"); + }); + } + + // The checkbox should already exist (static SSR) + bindToggle(document.getElementById("theme-toggle")); + + // Sync across tabs + window.addEventListener("storage", function (e) { + if (e.key === "theme") { + const dark = e.newValue === "dark-theme"; + applyTheme(dark); + const sw = document.getElementById("theme-toggle"); + if (sw) sw.checked = dark; + } + }); +})(); diff --git a/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css new file mode 100644 index 0000000..e6e5fe2 --- /dev/null +++ b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css @@ -0,0 +1,4085 @@ +/*! + * Bootstrap Grid v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +.container, +.container-fluid, +.container-xxl, +.container-xl, +.container-lg, +.container-md, +.container-sm { + --bs-gutter-x: 1.5rem; + --bs-gutter-y: 0; + width: 100%; + padding-right: calc(var(--bs-gutter-x) * 0.5); + padding-left: calc(var(--bs-gutter-x) * 0.5); + margin-right: auto; + margin-left: auto; +} + +@media (min-width: 576px) { + .container-sm, .container { + max-width: 540px; + } +} +@media (min-width: 768px) { + .container-md, .container-sm, .container { + max-width: 720px; + } +} +@media (min-width: 992px) { + .container-lg, .container-md, .container-sm, .container { + max-width: 960px; + } +} +@media (min-width: 1200px) { + .container-xl, .container-lg, .container-md, .container-sm, .container { + max-width: 1140px; + } +} +@media (min-width: 1400px) { + .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container { + max-width: 1320px; + } +} +:root { + --bs-breakpoint-xs: 0; + --bs-breakpoint-sm: 576px; + --bs-breakpoint-md: 768px; + --bs-breakpoint-lg: 992px; + --bs-breakpoint-xl: 1200px; + --bs-breakpoint-xxl: 1400px; +} + +.row { + --bs-gutter-x: 1.5rem; + --bs-gutter-y: 0; + display: flex; + flex-wrap: wrap; + margin-top: calc(-1 * var(--bs-gutter-y)); + margin-right: calc(-0.5 * var(--bs-gutter-x)); + margin-left: calc(-0.5 * var(--bs-gutter-x)); +} +.row > * { + box-sizing: border-box; + flex-shrink: 0; + width: 100%; + max-width: 100%; + padding-right: calc(var(--bs-gutter-x) * 0.5); + padding-left: calc(var(--bs-gutter-x) * 0.5); + margin-top: var(--bs-gutter-y); +} + +.col { + flex: 1 0 0%; +} + +.row-cols-auto > * { + flex: 0 0 auto; + width: auto; +} + +.row-cols-1 > * { + flex: 0 0 auto; + width: 100%; +} + +.row-cols-2 > * { + flex: 0 0 auto; + width: 50%; +} + +.row-cols-3 > * { + flex: 0 0 auto; + width: 33.33333333%; +} + +.row-cols-4 > * { + flex: 0 0 auto; + width: 25%; +} + +.row-cols-5 > * { + flex: 0 0 auto; + width: 20%; +} + +.row-cols-6 > * { + flex: 0 0 auto; + width: 16.66666667%; +} + +.col-auto { + flex: 0 0 auto; + width: auto; +} + +.col-1 { + flex: 0 0 auto; + width: 8.33333333%; +} + +.col-2 { + flex: 0 0 auto; + width: 16.66666667%; +} + +.col-3 { + flex: 0 0 auto; + width: 25%; +} + +.col-4 { + flex: 0 0 auto; + width: 33.33333333%; +} + +.col-5 { + flex: 0 0 auto; + width: 41.66666667%; +} + +.col-6 { + flex: 0 0 auto; + width: 50%; +} + +.col-7 { + flex: 0 0 auto; + width: 58.33333333%; +} + +.col-8 { + flex: 0 0 auto; + width: 66.66666667%; +} + +.col-9 { + flex: 0 0 auto; + width: 75%; +} + +.col-10 { + flex: 0 0 auto; + width: 83.33333333%; +} + +.col-11 { + flex: 0 0 auto; + width: 91.66666667%; +} + +.col-12 { + flex: 0 0 auto; + width: 100%; +} + +.offset-1 { + margin-left: 8.33333333%; +} + +.offset-2 { + margin-left: 16.66666667%; +} + +.offset-3 { + margin-left: 25%; +} + +.offset-4 { + margin-left: 33.33333333%; +} + +.offset-5 { + margin-left: 41.66666667%; +} + +.offset-6 { + margin-left: 50%; +} + +.offset-7 { + margin-left: 58.33333333%; +} + +.offset-8 { + margin-left: 66.66666667%; +} + +.offset-9 { + margin-left: 75%; +} + +.offset-10 { + margin-left: 83.33333333%; +} + +.offset-11 { + margin-left: 91.66666667%; +} + +.g-0, +.gx-0 { + --bs-gutter-x: 0; +} + +.g-0, +.gy-0 { + --bs-gutter-y: 0; +} + +.g-1, +.gx-1 { + --bs-gutter-x: 0.25rem; +} + +.g-1, +.gy-1 { + --bs-gutter-y: 0.25rem; +} + +.g-2, +.gx-2 { + --bs-gutter-x: 0.5rem; +} + +.g-2, +.gy-2 { + --bs-gutter-y: 0.5rem; +} + +.g-3, +.gx-3 { + --bs-gutter-x: 1rem; +} + +.g-3, +.gy-3 { + --bs-gutter-y: 1rem; +} + +.g-4, +.gx-4 { + --bs-gutter-x: 1.5rem; +} + +.g-4, +.gy-4 { + --bs-gutter-y: 1.5rem; +} + +.g-5, +.gx-5 { + --bs-gutter-x: 3rem; +} + +.g-5, +.gy-5 { + --bs-gutter-y: 3rem; +} + +@media (min-width: 576px) { + .col-sm { + flex: 1 0 0%; + } + .row-cols-sm-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-sm-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-sm-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-sm-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-sm-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-sm-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-sm-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-sm-auto { + flex: 0 0 auto; + width: auto; + } + .col-sm-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-sm-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-sm-3 { + flex: 0 0 auto; + width: 25%; + } + .col-sm-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-sm-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-sm-6 { + flex: 0 0 auto; + width: 50%; + } + .col-sm-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-sm-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-sm-9 { + flex: 0 0 auto; + width: 75%; + } + .col-sm-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-sm-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-sm-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-sm-0 { + margin-left: 0; + } + .offset-sm-1 { + margin-left: 8.33333333%; + } + .offset-sm-2 { + margin-left: 16.66666667%; + } + .offset-sm-3 { + margin-left: 25%; + } + .offset-sm-4 { + margin-left: 33.33333333%; + } + .offset-sm-5 { + margin-left: 41.66666667%; + } + .offset-sm-6 { + margin-left: 50%; + } + .offset-sm-7 { + margin-left: 58.33333333%; + } + .offset-sm-8 { + margin-left: 66.66666667%; + } + .offset-sm-9 { + margin-left: 75%; + } + .offset-sm-10 { + margin-left: 83.33333333%; + } + .offset-sm-11 { + margin-left: 91.66666667%; + } + .g-sm-0, + .gx-sm-0 { + --bs-gutter-x: 0; + } + .g-sm-0, + .gy-sm-0 { + --bs-gutter-y: 0; + } + .g-sm-1, + .gx-sm-1 { + --bs-gutter-x: 0.25rem; + } + .g-sm-1, + .gy-sm-1 { + --bs-gutter-y: 0.25rem; + } + .g-sm-2, + .gx-sm-2 { + --bs-gutter-x: 0.5rem; + } + .g-sm-2, + .gy-sm-2 { + --bs-gutter-y: 0.5rem; + } + .g-sm-3, + .gx-sm-3 { + --bs-gutter-x: 1rem; + } + .g-sm-3, + .gy-sm-3 { + --bs-gutter-y: 1rem; + } + .g-sm-4, + .gx-sm-4 { + --bs-gutter-x: 1.5rem; + } + .g-sm-4, + .gy-sm-4 { + --bs-gutter-y: 1.5rem; + } + .g-sm-5, + .gx-sm-5 { + --bs-gutter-x: 3rem; + } + .g-sm-5, + .gy-sm-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 768px) { + .col-md { + flex: 1 0 0%; + } + .row-cols-md-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-md-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-md-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-md-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-md-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-md-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-md-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-md-auto { + flex: 0 0 auto; + width: auto; + } + .col-md-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-md-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-md-3 { + flex: 0 0 auto; + width: 25%; + } + .col-md-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-md-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-md-6 { + flex: 0 0 auto; + width: 50%; + } + .col-md-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-md-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-md-9 { + flex: 0 0 auto; + width: 75%; + } + .col-md-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-md-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-md-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-md-0 { + margin-left: 0; + } + .offset-md-1 { + margin-left: 8.33333333%; + } + .offset-md-2 { + margin-left: 16.66666667%; + } + .offset-md-3 { + margin-left: 25%; + } + .offset-md-4 { + margin-left: 33.33333333%; + } + .offset-md-5 { + margin-left: 41.66666667%; + } + .offset-md-6 { + margin-left: 50%; + } + .offset-md-7 { + margin-left: 58.33333333%; + } + .offset-md-8 { + margin-left: 66.66666667%; + } + .offset-md-9 { + margin-left: 75%; + } + .offset-md-10 { + margin-left: 83.33333333%; + } + .offset-md-11 { + margin-left: 91.66666667%; + } + .g-md-0, + .gx-md-0 { + --bs-gutter-x: 0; + } + .g-md-0, + .gy-md-0 { + --bs-gutter-y: 0; + } + .g-md-1, + .gx-md-1 { + --bs-gutter-x: 0.25rem; + } + .g-md-1, + .gy-md-1 { + --bs-gutter-y: 0.25rem; + } + .g-md-2, + .gx-md-2 { + --bs-gutter-x: 0.5rem; + } + .g-md-2, + .gy-md-2 { + --bs-gutter-y: 0.5rem; + } + .g-md-3, + .gx-md-3 { + --bs-gutter-x: 1rem; + } + .g-md-3, + .gy-md-3 { + --bs-gutter-y: 1rem; + } + .g-md-4, + .gx-md-4 { + --bs-gutter-x: 1.5rem; + } + .g-md-4, + .gy-md-4 { + --bs-gutter-y: 1.5rem; + } + .g-md-5, + .gx-md-5 { + --bs-gutter-x: 3rem; + } + .g-md-5, + .gy-md-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 992px) { + .col-lg { + flex: 1 0 0%; + } + .row-cols-lg-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-lg-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-lg-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-lg-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-lg-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-lg-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-lg-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-lg-auto { + flex: 0 0 auto; + width: auto; + } + .col-lg-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-lg-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-lg-3 { + flex: 0 0 auto; + width: 25%; + } + .col-lg-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-lg-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-lg-6 { + flex: 0 0 auto; + width: 50%; + } + .col-lg-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-lg-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-lg-9 { + flex: 0 0 auto; + width: 75%; + } + .col-lg-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-lg-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-lg-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-lg-0 { + margin-left: 0; + } + .offset-lg-1 { + margin-left: 8.33333333%; + } + .offset-lg-2 { + margin-left: 16.66666667%; + } + .offset-lg-3 { + margin-left: 25%; + } + .offset-lg-4 { + margin-left: 33.33333333%; + } + .offset-lg-5 { + margin-left: 41.66666667%; + } + .offset-lg-6 { + margin-left: 50%; + } + .offset-lg-7 { + margin-left: 58.33333333%; + } + .offset-lg-8 { + margin-left: 66.66666667%; + } + .offset-lg-9 { + margin-left: 75%; + } + .offset-lg-10 { + margin-left: 83.33333333%; + } + .offset-lg-11 { + margin-left: 91.66666667%; + } + .g-lg-0, + .gx-lg-0 { + --bs-gutter-x: 0; + } + .g-lg-0, + .gy-lg-0 { + --bs-gutter-y: 0; + } + .g-lg-1, + .gx-lg-1 { + --bs-gutter-x: 0.25rem; + } + .g-lg-1, + .gy-lg-1 { + --bs-gutter-y: 0.25rem; + } + .g-lg-2, + .gx-lg-2 { + --bs-gutter-x: 0.5rem; + } + .g-lg-2, + .gy-lg-2 { + --bs-gutter-y: 0.5rem; + } + .g-lg-3, + .gx-lg-3 { + --bs-gutter-x: 1rem; + } + .g-lg-3, + .gy-lg-3 { + --bs-gutter-y: 1rem; + } + .g-lg-4, + .gx-lg-4 { + --bs-gutter-x: 1.5rem; + } + .g-lg-4, + .gy-lg-4 { + --bs-gutter-y: 1.5rem; + } + .g-lg-5, + .gx-lg-5 { + --bs-gutter-x: 3rem; + } + .g-lg-5, + .gy-lg-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 1200px) { + .col-xl { + flex: 1 0 0%; + } + .row-cols-xl-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-xl-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-xl-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-xl-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-xl-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-xl-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-xl-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xl-auto { + flex: 0 0 auto; + width: auto; + } + .col-xl-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-xl-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xl-3 { + flex: 0 0 auto; + width: 25%; + } + .col-xl-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-xl-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-xl-6 { + flex: 0 0 auto; + width: 50%; + } + .col-xl-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-xl-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-xl-9 { + flex: 0 0 auto; + width: 75%; + } + .col-xl-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-xl-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-xl-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-xl-0 { + margin-left: 0; + } + .offset-xl-1 { + margin-left: 8.33333333%; + } + .offset-xl-2 { + margin-left: 16.66666667%; + } + .offset-xl-3 { + margin-left: 25%; + } + .offset-xl-4 { + margin-left: 33.33333333%; + } + .offset-xl-5 { + margin-left: 41.66666667%; + } + .offset-xl-6 { + margin-left: 50%; + } + .offset-xl-7 { + margin-left: 58.33333333%; + } + .offset-xl-8 { + margin-left: 66.66666667%; + } + .offset-xl-9 { + margin-left: 75%; + } + .offset-xl-10 { + margin-left: 83.33333333%; + } + .offset-xl-11 { + margin-left: 91.66666667%; + } + .g-xl-0, + .gx-xl-0 { + --bs-gutter-x: 0; + } + .g-xl-0, + .gy-xl-0 { + --bs-gutter-y: 0; + } + .g-xl-1, + .gx-xl-1 { + --bs-gutter-x: 0.25rem; + } + .g-xl-1, + .gy-xl-1 { + --bs-gutter-y: 0.25rem; + } + .g-xl-2, + .gx-xl-2 { + --bs-gutter-x: 0.5rem; + } + .g-xl-2, + .gy-xl-2 { + --bs-gutter-y: 0.5rem; + } + .g-xl-3, + .gx-xl-3 { + --bs-gutter-x: 1rem; + } + .g-xl-3, + .gy-xl-3 { + --bs-gutter-y: 1rem; + } + .g-xl-4, + .gx-xl-4 { + --bs-gutter-x: 1.5rem; + } + .g-xl-4, + .gy-xl-4 { + --bs-gutter-y: 1.5rem; + } + .g-xl-5, + .gx-xl-5 { + --bs-gutter-x: 3rem; + } + .g-xl-5, + .gy-xl-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 1400px) { + .col-xxl { + flex: 1 0 0%; + } + .row-cols-xxl-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-xxl-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-xxl-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-xxl-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-xxl-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-xxl-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-xxl-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xxl-auto { + flex: 0 0 auto; + width: auto; + } + .col-xxl-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-xxl-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xxl-3 { + flex: 0 0 auto; + width: 25%; + } + .col-xxl-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-xxl-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-xxl-6 { + flex: 0 0 auto; + width: 50%; + } + .col-xxl-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-xxl-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-xxl-9 { + flex: 0 0 auto; + width: 75%; + } + .col-xxl-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-xxl-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-xxl-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-xxl-0 { + margin-left: 0; + } + .offset-xxl-1 { + margin-left: 8.33333333%; + } + .offset-xxl-2 { + margin-left: 16.66666667%; + } + .offset-xxl-3 { + margin-left: 25%; + } + .offset-xxl-4 { + margin-left: 33.33333333%; + } + .offset-xxl-5 { + margin-left: 41.66666667%; + } + .offset-xxl-6 { + margin-left: 50%; + } + .offset-xxl-7 { + margin-left: 58.33333333%; + } + .offset-xxl-8 { + margin-left: 66.66666667%; + } + .offset-xxl-9 { + margin-left: 75%; + } + .offset-xxl-10 { + margin-left: 83.33333333%; + } + .offset-xxl-11 { + margin-left: 91.66666667%; + } + .g-xxl-0, + .gx-xxl-0 { + --bs-gutter-x: 0; + } + .g-xxl-0, + .gy-xxl-0 { + --bs-gutter-y: 0; + } + .g-xxl-1, + .gx-xxl-1 { + --bs-gutter-x: 0.25rem; + } + .g-xxl-1, + .gy-xxl-1 { + --bs-gutter-y: 0.25rem; + } + .g-xxl-2, + .gx-xxl-2 { + --bs-gutter-x: 0.5rem; + } + .g-xxl-2, + .gy-xxl-2 { + --bs-gutter-y: 0.5rem; + } + .g-xxl-3, + .gx-xxl-3 { + --bs-gutter-x: 1rem; + } + .g-xxl-3, + .gy-xxl-3 { + --bs-gutter-y: 1rem; + } + .g-xxl-4, + .gx-xxl-4 { + --bs-gutter-x: 1.5rem; + } + .g-xxl-4, + .gy-xxl-4 { + --bs-gutter-y: 1.5rem; + } + .g-xxl-5, + .gx-xxl-5 { + --bs-gutter-x: 3rem; + } + .g-xxl-5, + .gy-xxl-5 { + --bs-gutter-y: 3rem; + } +} +.d-inline { + display: inline !important; +} + +.d-inline-block { + display: inline-block !important; +} + +.d-block { + display: block !important; +} + +.d-grid { + display: grid !important; +} + +.d-inline-grid { + display: inline-grid !important; +} + +.d-table { + display: table !important; +} + +.d-table-row { + display: table-row !important; +} + +.d-table-cell { + display: table-cell !important; +} + +.d-flex { + display: flex !important; +} + +.d-inline-flex { + display: inline-flex !important; +} + +.d-none { + display: none !important; +} + +.flex-fill { + flex: 1 1 auto !important; +} + +.flex-row { + flex-direction: row !important; +} + +.flex-column { + flex-direction: column !important; +} + +.flex-row-reverse { + flex-direction: row-reverse !important; +} + +.flex-column-reverse { + flex-direction: column-reverse !important; +} + +.flex-grow-0 { + flex-grow: 0 !important; +} + +.flex-grow-1 { + flex-grow: 1 !important; +} + +.flex-shrink-0 { + flex-shrink: 0 !important; +} + +.flex-shrink-1 { + flex-shrink: 1 !important; +} + +.flex-wrap { + flex-wrap: wrap !important; +} + +.flex-nowrap { + flex-wrap: nowrap !important; +} + +.flex-wrap-reverse { + flex-wrap: wrap-reverse !important; +} + +.justify-content-start { + justify-content: flex-start !important; +} + +.justify-content-end { + justify-content: flex-end !important; +} + +.justify-content-center { + justify-content: center !important; +} + +.justify-content-between { + justify-content: space-between !important; +} + +.justify-content-around { + justify-content: space-around !important; +} + +.justify-content-evenly { + justify-content: space-evenly !important; +} + +.align-items-start { + align-items: flex-start !important; +} + +.align-items-end { + align-items: flex-end !important; +} + +.align-items-center { + align-items: center !important; +} + +.align-items-baseline { + align-items: baseline !important; +} + +.align-items-stretch { + align-items: stretch !important; +} + +.align-content-start { + align-content: flex-start !important; +} + +.align-content-end { + align-content: flex-end !important; +} + +.align-content-center { + align-content: center !important; +} + +.align-content-between { + align-content: space-between !important; +} + +.align-content-around { + align-content: space-around !important; +} + +.align-content-stretch { + align-content: stretch !important; +} + +.align-self-auto { + align-self: auto !important; +} + +.align-self-start { + align-self: flex-start !important; +} + +.align-self-end { + align-self: flex-end !important; +} + +.align-self-center { + align-self: center !important; +} + +.align-self-baseline { + align-self: baseline !important; +} + +.align-self-stretch { + align-self: stretch !important; +} + +.order-first { + order: -1 !important; +} + +.order-0 { + order: 0 !important; +} + +.order-1 { + order: 1 !important; +} + +.order-2 { + order: 2 !important; +} + +.order-3 { + order: 3 !important; +} + +.order-4 { + order: 4 !important; +} + +.order-5 { + order: 5 !important; +} + +.order-last { + order: 6 !important; +} + +.m-0 { + margin: 0 !important; +} + +.m-1 { + margin: 0.25rem !important; +} + +.m-2 { + margin: 0.5rem !important; +} + +.m-3 { + margin: 1rem !important; +} + +.m-4 { + margin: 1.5rem !important; +} + +.m-5 { + margin: 3rem !important; +} + +.m-auto { + margin: auto !important; +} + +.mx-0 { + margin-right: 0 !important; + margin-left: 0 !important; +} + +.mx-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; +} + +.mx-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; +} + +.mx-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; +} + +.mx-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; +} + +.mx-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; +} + +.mx-auto { + margin-right: auto !important; + margin-left: auto !important; +} + +.my-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; +} + +.my-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; +} + +.my-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; +} + +.my-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; +} + +.my-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; +} + +.my-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; +} + +.my-auto { + margin-top: auto !important; + margin-bottom: auto !important; +} + +.mt-0 { + margin-top: 0 !important; +} + +.mt-1 { + margin-top: 0.25rem !important; +} + +.mt-2 { + margin-top: 0.5rem !important; +} + +.mt-3 { + margin-top: 1rem !important; +} + +.mt-4 { + margin-top: 1.5rem !important; +} + +.mt-5 { + margin-top: 3rem !important; +} + +.mt-auto { + margin-top: auto !important; +} + +.me-0 { + margin-right: 0 !important; +} + +.me-1 { + margin-right: 0.25rem !important; +} + +.me-2 { + margin-right: 0.5rem !important; +} + +.me-3 { + margin-right: 1rem !important; +} + +.me-4 { + margin-right: 1.5rem !important; +} + +.me-5 { + margin-right: 3rem !important; +} + +.me-auto { + margin-right: auto !important; +} + +.mb-0 { + margin-bottom: 0 !important; +} + +.mb-1 { + margin-bottom: 0.25rem !important; +} + +.mb-2 { + margin-bottom: 0.5rem !important; +} + +.mb-3 { + margin-bottom: 1rem !important; +} + +.mb-4 { + margin-bottom: 1.5rem !important; +} + +.mb-5 { + margin-bottom: 3rem !important; +} + +.mb-auto { + margin-bottom: auto !important; +} + +.ms-0 { + margin-left: 0 !important; +} + +.ms-1 { + margin-left: 0.25rem !important; +} + +.ms-2 { + margin-left: 0.5rem !important; +} + +.ms-3 { + margin-left: 1rem !important; +} + +.ms-4 { + margin-left: 1.5rem !important; +} + +.ms-5 { + margin-left: 3rem !important; +} + +.ms-auto { + margin-left: auto !important; +} + +.p-0 { + padding: 0 !important; +} + +.p-1 { + padding: 0.25rem !important; +} + +.p-2 { + padding: 0.5rem !important; +} + +.p-3 { + padding: 1rem !important; +} + +.p-4 { + padding: 1.5rem !important; +} + +.p-5 { + padding: 3rem !important; +} + +.px-0 { + padding-right: 0 !important; + padding-left: 0 !important; +} + +.px-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; +} + +.px-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; +} + +.px-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; +} + +.px-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; +} + +.px-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; +} + +.py-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.py-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; +} + +.py-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; +} + +.py-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; +} + +.py-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; +} + +.py-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; +} + +.pt-0 { + padding-top: 0 !important; +} + +.pt-1 { + padding-top: 0.25rem !important; +} + +.pt-2 { + padding-top: 0.5rem !important; +} + +.pt-3 { + padding-top: 1rem !important; +} + +.pt-4 { + padding-top: 1.5rem !important; +} + +.pt-5 { + padding-top: 3rem !important; +} + +.pe-0 { + padding-right: 0 !important; +} + +.pe-1 { + padding-right: 0.25rem !important; +} + +.pe-2 { + padding-right: 0.5rem !important; +} + +.pe-3 { + padding-right: 1rem !important; +} + +.pe-4 { + padding-right: 1.5rem !important; +} + +.pe-5 { + padding-right: 3rem !important; +} + +.pb-0 { + padding-bottom: 0 !important; +} + +.pb-1 { + padding-bottom: 0.25rem !important; +} + +.pb-2 { + padding-bottom: 0.5rem !important; +} + +.pb-3 { + padding-bottom: 1rem !important; +} + +.pb-4 { + padding-bottom: 1.5rem !important; +} + +.pb-5 { + padding-bottom: 3rem !important; +} + +.ps-0 { + padding-left: 0 !important; +} + +.ps-1 { + padding-left: 0.25rem !important; +} + +.ps-2 { + padding-left: 0.5rem !important; +} + +.ps-3 { + padding-left: 1rem !important; +} + +.ps-4 { + padding-left: 1.5rem !important; +} + +.ps-5 { + padding-left: 3rem !important; +} + +@media (min-width: 576px) { + .d-sm-inline { + display: inline !important; + } + .d-sm-inline-block { + display: inline-block !important; + } + .d-sm-block { + display: block !important; + } + .d-sm-grid { + display: grid !important; + } + .d-sm-inline-grid { + display: inline-grid !important; + } + .d-sm-table { + display: table !important; + } + .d-sm-table-row { + display: table-row !important; + } + .d-sm-table-cell { + display: table-cell !important; + } + .d-sm-flex { + display: flex !important; + } + .d-sm-inline-flex { + display: inline-flex !important; + } + .d-sm-none { + display: none !important; + } + .flex-sm-fill { + flex: 1 1 auto !important; + } + .flex-sm-row { + flex-direction: row !important; + } + .flex-sm-column { + flex-direction: column !important; + } + .flex-sm-row-reverse { + flex-direction: row-reverse !important; + } + .flex-sm-column-reverse { + flex-direction: column-reverse !important; + } + .flex-sm-grow-0 { + flex-grow: 0 !important; + } + .flex-sm-grow-1 { + flex-grow: 1 !important; + } + .flex-sm-shrink-0 { + flex-shrink: 0 !important; + } + .flex-sm-shrink-1 { + flex-shrink: 1 !important; + } + .flex-sm-wrap { + flex-wrap: wrap !important; + } + .flex-sm-nowrap { + flex-wrap: nowrap !important; + } + .flex-sm-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-sm-start { + justify-content: flex-start !important; + } + .justify-content-sm-end { + justify-content: flex-end !important; + } + .justify-content-sm-center { + justify-content: center !important; + } + .justify-content-sm-between { + justify-content: space-between !important; + } + .justify-content-sm-around { + justify-content: space-around !important; + } + .justify-content-sm-evenly { + justify-content: space-evenly !important; + } + .align-items-sm-start { + align-items: flex-start !important; + } + .align-items-sm-end { + align-items: flex-end !important; + } + .align-items-sm-center { + align-items: center !important; + } + .align-items-sm-baseline { + align-items: baseline !important; + } + .align-items-sm-stretch { + align-items: stretch !important; + } + .align-content-sm-start { + align-content: flex-start !important; + } + .align-content-sm-end { + align-content: flex-end !important; + } + .align-content-sm-center { + align-content: center !important; + } + .align-content-sm-between { + align-content: space-between !important; + } + .align-content-sm-around { + align-content: space-around !important; + } + .align-content-sm-stretch { + align-content: stretch !important; + } + .align-self-sm-auto { + align-self: auto !important; + } + .align-self-sm-start { + align-self: flex-start !important; + } + .align-self-sm-end { + align-self: flex-end !important; + } + .align-self-sm-center { + align-self: center !important; + } + .align-self-sm-baseline { + align-self: baseline !important; + } + .align-self-sm-stretch { + align-self: stretch !important; + } + .order-sm-first { + order: -1 !important; + } + .order-sm-0 { + order: 0 !important; + } + .order-sm-1 { + order: 1 !important; + } + .order-sm-2 { + order: 2 !important; + } + .order-sm-3 { + order: 3 !important; + } + .order-sm-4 { + order: 4 !important; + } + .order-sm-5 { + order: 5 !important; + } + .order-sm-last { + order: 6 !important; + } + .m-sm-0 { + margin: 0 !important; + } + .m-sm-1 { + margin: 0.25rem !important; + } + .m-sm-2 { + margin: 0.5rem !important; + } + .m-sm-3 { + margin: 1rem !important; + } + .m-sm-4 { + margin: 1.5rem !important; + } + .m-sm-5 { + margin: 3rem !important; + } + .m-sm-auto { + margin: auto !important; + } + .mx-sm-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + .mx-sm-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-sm-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + .mx-sm-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + .mx-sm-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + .mx-sm-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + .mx-sm-auto { + margin-right: auto !important; + margin-left: auto !important; + } + .my-sm-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-sm-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-sm-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-sm-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-sm-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-sm-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-sm-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-sm-0 { + margin-top: 0 !important; + } + .mt-sm-1 { + margin-top: 0.25rem !important; + } + .mt-sm-2 { + margin-top: 0.5rem !important; + } + .mt-sm-3 { + margin-top: 1rem !important; + } + .mt-sm-4 { + margin-top: 1.5rem !important; + } + .mt-sm-5 { + margin-top: 3rem !important; + } + .mt-sm-auto { + margin-top: auto !important; + } + .me-sm-0 { + margin-right: 0 !important; + } + .me-sm-1 { + margin-right: 0.25rem !important; + } + .me-sm-2 { + margin-right: 0.5rem !important; + } + .me-sm-3 { + margin-right: 1rem !important; + } + .me-sm-4 { + margin-right: 1.5rem !important; + } + .me-sm-5 { + margin-right: 3rem !important; + } + .me-sm-auto { + margin-right: auto !important; + } + .mb-sm-0 { + margin-bottom: 0 !important; + } + .mb-sm-1 { + margin-bottom: 0.25rem !important; + } + .mb-sm-2 { + margin-bottom: 0.5rem !important; + } + .mb-sm-3 { + margin-bottom: 1rem !important; + } + .mb-sm-4 { + margin-bottom: 1.5rem !important; + } + .mb-sm-5 { + margin-bottom: 3rem !important; + } + .mb-sm-auto { + margin-bottom: auto !important; + } + .ms-sm-0 { + margin-left: 0 !important; + } + .ms-sm-1 { + margin-left: 0.25rem !important; + } + .ms-sm-2 { + margin-left: 0.5rem !important; + } + .ms-sm-3 { + margin-left: 1rem !important; + } + .ms-sm-4 { + margin-left: 1.5rem !important; + } + .ms-sm-5 { + margin-left: 3rem !important; + } + .ms-sm-auto { + margin-left: auto !important; + } + .p-sm-0 { + padding: 0 !important; + } + .p-sm-1 { + padding: 0.25rem !important; + } + .p-sm-2 { + padding: 0.5rem !important; + } + .p-sm-3 { + padding: 1rem !important; + } + .p-sm-4 { + padding: 1.5rem !important; + } + .p-sm-5 { + padding: 3rem !important; + } + .px-sm-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + .px-sm-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-sm-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + .px-sm-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + .px-sm-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + .px-sm-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + .py-sm-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-sm-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-sm-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-sm-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-sm-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-sm-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-sm-0 { + padding-top: 0 !important; + } + .pt-sm-1 { + padding-top: 0.25rem !important; + } + .pt-sm-2 { + padding-top: 0.5rem !important; + } + .pt-sm-3 { + padding-top: 1rem !important; + } + .pt-sm-4 { + padding-top: 1.5rem !important; + } + .pt-sm-5 { + padding-top: 3rem !important; + } + .pe-sm-0 { + padding-right: 0 !important; + } + .pe-sm-1 { + padding-right: 0.25rem !important; + } + .pe-sm-2 { + padding-right: 0.5rem !important; + } + .pe-sm-3 { + padding-right: 1rem !important; + } + .pe-sm-4 { + padding-right: 1.5rem !important; + } + .pe-sm-5 { + padding-right: 3rem !important; + } + .pb-sm-0 { + padding-bottom: 0 !important; + } + .pb-sm-1 { + padding-bottom: 0.25rem !important; + } + .pb-sm-2 { + padding-bottom: 0.5rem !important; + } + .pb-sm-3 { + padding-bottom: 1rem !important; + } + .pb-sm-4 { + padding-bottom: 1.5rem !important; + } + .pb-sm-5 { + padding-bottom: 3rem !important; + } + .ps-sm-0 { + padding-left: 0 !important; + } + .ps-sm-1 { + padding-left: 0.25rem !important; + } + .ps-sm-2 { + padding-left: 0.5rem !important; + } + .ps-sm-3 { + padding-left: 1rem !important; + } + .ps-sm-4 { + padding-left: 1.5rem !important; + } + .ps-sm-5 { + padding-left: 3rem !important; + } +} +@media (min-width: 768px) { + .d-md-inline { + display: inline !important; + } + .d-md-inline-block { + display: inline-block !important; + } + .d-md-block { + display: block !important; + } + .d-md-grid { + display: grid !important; + } + .d-md-inline-grid { + display: inline-grid !important; + } + .d-md-table { + display: table !important; + } + .d-md-table-row { + display: table-row !important; + } + .d-md-table-cell { + display: table-cell !important; + } + .d-md-flex { + display: flex !important; + } + .d-md-inline-flex { + display: inline-flex !important; + } + .d-md-none { + display: none !important; + } + .flex-md-fill { + flex: 1 1 auto !important; + } + .flex-md-row { + flex-direction: row !important; + } + .flex-md-column { + flex-direction: column !important; + } + .flex-md-row-reverse { + flex-direction: row-reverse !important; + } + .flex-md-column-reverse { + flex-direction: column-reverse !important; + } + .flex-md-grow-0 { + flex-grow: 0 !important; + } + .flex-md-grow-1 { + flex-grow: 1 !important; + } + .flex-md-shrink-0 { + flex-shrink: 0 !important; + } + .flex-md-shrink-1 { + flex-shrink: 1 !important; + } + .flex-md-wrap { + flex-wrap: wrap !important; + } + .flex-md-nowrap { + flex-wrap: nowrap !important; + } + .flex-md-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-md-start { + justify-content: flex-start !important; + } + .justify-content-md-end { + justify-content: flex-end !important; + } + .justify-content-md-center { + justify-content: center !important; + } + .justify-content-md-between { + justify-content: space-between !important; + } + .justify-content-md-around { + justify-content: space-around !important; + } + .justify-content-md-evenly { + justify-content: space-evenly !important; + } + .align-items-md-start { + align-items: flex-start !important; + } + .align-items-md-end { + align-items: flex-end !important; + } + .align-items-md-center { + align-items: center !important; + } + .align-items-md-baseline { + align-items: baseline !important; + } + .align-items-md-stretch { + align-items: stretch !important; + } + .align-content-md-start { + align-content: flex-start !important; + } + .align-content-md-end { + align-content: flex-end !important; + } + .align-content-md-center { + align-content: center !important; + } + .align-content-md-between { + align-content: space-between !important; + } + .align-content-md-around { + align-content: space-around !important; + } + .align-content-md-stretch { + align-content: stretch !important; + } + .align-self-md-auto { + align-self: auto !important; + } + .align-self-md-start { + align-self: flex-start !important; + } + .align-self-md-end { + align-self: flex-end !important; + } + .align-self-md-center { + align-self: center !important; + } + .align-self-md-baseline { + align-self: baseline !important; + } + .align-self-md-stretch { + align-self: stretch !important; + } + .order-md-first { + order: -1 !important; + } + .order-md-0 { + order: 0 !important; + } + .order-md-1 { + order: 1 !important; + } + .order-md-2 { + order: 2 !important; + } + .order-md-3 { + order: 3 !important; + } + .order-md-4 { + order: 4 !important; + } + .order-md-5 { + order: 5 !important; + } + .order-md-last { + order: 6 !important; + } + .m-md-0 { + margin: 0 !important; + } + .m-md-1 { + margin: 0.25rem !important; + } + .m-md-2 { + margin: 0.5rem !important; + } + .m-md-3 { + margin: 1rem !important; + } + .m-md-4 { + margin: 1.5rem !important; + } + .m-md-5 { + margin: 3rem !important; + } + .m-md-auto { + margin: auto !important; + } + .mx-md-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + .mx-md-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-md-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + .mx-md-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + .mx-md-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + .mx-md-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + .mx-md-auto { + margin-right: auto !important; + margin-left: auto !important; + } + .my-md-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-md-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-md-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-md-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-md-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-md-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-md-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-md-0 { + margin-top: 0 !important; + } + .mt-md-1 { + margin-top: 0.25rem !important; + } + .mt-md-2 { + margin-top: 0.5rem !important; + } + .mt-md-3 { + margin-top: 1rem !important; + } + .mt-md-4 { + margin-top: 1.5rem !important; + } + .mt-md-5 { + margin-top: 3rem !important; + } + .mt-md-auto { + margin-top: auto !important; + } + .me-md-0 { + margin-right: 0 !important; + } + .me-md-1 { + margin-right: 0.25rem !important; + } + .me-md-2 { + margin-right: 0.5rem !important; + } + .me-md-3 { + margin-right: 1rem !important; + } + .me-md-4 { + margin-right: 1.5rem !important; + } + .me-md-5 { + margin-right: 3rem !important; + } + .me-md-auto { + margin-right: auto !important; + } + .mb-md-0 { + margin-bottom: 0 !important; + } + .mb-md-1 { + margin-bottom: 0.25rem !important; + } + .mb-md-2 { + margin-bottom: 0.5rem !important; + } + .mb-md-3 { + margin-bottom: 1rem !important; + } + .mb-md-4 { + margin-bottom: 1.5rem !important; + } + .mb-md-5 { + margin-bottom: 3rem !important; + } + .mb-md-auto { + margin-bottom: auto !important; + } + .ms-md-0 { + margin-left: 0 !important; + } + .ms-md-1 { + margin-left: 0.25rem !important; + } + .ms-md-2 { + margin-left: 0.5rem !important; + } + .ms-md-3 { + margin-left: 1rem !important; + } + .ms-md-4 { + margin-left: 1.5rem !important; + } + .ms-md-5 { + margin-left: 3rem !important; + } + .ms-md-auto { + margin-left: auto !important; + } + .p-md-0 { + padding: 0 !important; + } + .p-md-1 { + padding: 0.25rem !important; + } + .p-md-2 { + padding: 0.5rem !important; + } + .p-md-3 { + padding: 1rem !important; + } + .p-md-4 { + padding: 1.5rem !important; + } + .p-md-5 { + padding: 3rem !important; + } + .px-md-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + .px-md-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-md-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + .px-md-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + .px-md-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + .px-md-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + .py-md-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-md-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-md-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-md-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-md-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-md-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-md-0 { + padding-top: 0 !important; + } + .pt-md-1 { + padding-top: 0.25rem !important; + } + .pt-md-2 { + padding-top: 0.5rem !important; + } + .pt-md-3 { + padding-top: 1rem !important; + } + .pt-md-4 { + padding-top: 1.5rem !important; + } + .pt-md-5 { + padding-top: 3rem !important; + } + .pe-md-0 { + padding-right: 0 !important; + } + .pe-md-1 { + padding-right: 0.25rem !important; + } + .pe-md-2 { + padding-right: 0.5rem !important; + } + .pe-md-3 { + padding-right: 1rem !important; + } + .pe-md-4 { + padding-right: 1.5rem !important; + } + .pe-md-5 { + padding-right: 3rem !important; + } + .pb-md-0 { + padding-bottom: 0 !important; + } + .pb-md-1 { + padding-bottom: 0.25rem !important; + } + .pb-md-2 { + padding-bottom: 0.5rem !important; + } + .pb-md-3 { + padding-bottom: 1rem !important; + } + .pb-md-4 { + padding-bottom: 1.5rem !important; + } + .pb-md-5 { + padding-bottom: 3rem !important; + } + .ps-md-0 { + padding-left: 0 !important; + } + .ps-md-1 { + padding-left: 0.25rem !important; + } + .ps-md-2 { + padding-left: 0.5rem !important; + } + .ps-md-3 { + padding-left: 1rem !important; + } + .ps-md-4 { + padding-left: 1.5rem !important; + } + .ps-md-5 { + padding-left: 3rem !important; + } +} +@media (min-width: 992px) { + .d-lg-inline { + display: inline !important; + } + .d-lg-inline-block { + display: inline-block !important; + } + .d-lg-block { + display: block !important; + } + .d-lg-grid { + display: grid !important; + } + .d-lg-inline-grid { + display: inline-grid !important; + } + .d-lg-table { + display: table !important; + } + .d-lg-table-row { + display: table-row !important; + } + .d-lg-table-cell { + display: table-cell !important; + } + .d-lg-flex { + display: flex !important; + } + .d-lg-inline-flex { + display: inline-flex !important; + } + .d-lg-none { + display: none !important; + } + .flex-lg-fill { + flex: 1 1 auto !important; + } + .flex-lg-row { + flex-direction: row !important; + } + .flex-lg-column { + flex-direction: column !important; + } + .flex-lg-row-reverse { + flex-direction: row-reverse !important; + } + .flex-lg-column-reverse { + flex-direction: column-reverse !important; + } + .flex-lg-grow-0 { + flex-grow: 0 !important; + } + .flex-lg-grow-1 { + flex-grow: 1 !important; + } + .flex-lg-shrink-0 { + flex-shrink: 0 !important; + } + .flex-lg-shrink-1 { + flex-shrink: 1 !important; + } + .flex-lg-wrap { + flex-wrap: wrap !important; + } + .flex-lg-nowrap { + flex-wrap: nowrap !important; + } + .flex-lg-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-lg-start { + justify-content: flex-start !important; + } + .justify-content-lg-end { + justify-content: flex-end !important; + } + .justify-content-lg-center { + justify-content: center !important; + } + .justify-content-lg-between { + justify-content: space-between !important; + } + .justify-content-lg-around { + justify-content: space-around !important; + } + .justify-content-lg-evenly { + justify-content: space-evenly !important; + } + .align-items-lg-start { + align-items: flex-start !important; + } + .align-items-lg-end { + align-items: flex-end !important; + } + .align-items-lg-center { + align-items: center !important; + } + .align-items-lg-baseline { + align-items: baseline !important; + } + .align-items-lg-stretch { + align-items: stretch !important; + } + .align-content-lg-start { + align-content: flex-start !important; + } + .align-content-lg-end { + align-content: flex-end !important; + } + .align-content-lg-center { + align-content: center !important; + } + .align-content-lg-between { + align-content: space-between !important; + } + .align-content-lg-around { + align-content: space-around !important; + } + .align-content-lg-stretch { + align-content: stretch !important; + } + .align-self-lg-auto { + align-self: auto !important; + } + .align-self-lg-start { + align-self: flex-start !important; + } + .align-self-lg-end { + align-self: flex-end !important; + } + .align-self-lg-center { + align-self: center !important; + } + .align-self-lg-baseline { + align-self: baseline !important; + } + .align-self-lg-stretch { + align-self: stretch !important; + } + .order-lg-first { + order: -1 !important; + } + .order-lg-0 { + order: 0 !important; + } + .order-lg-1 { + order: 1 !important; + } + .order-lg-2 { + order: 2 !important; + } + .order-lg-3 { + order: 3 !important; + } + .order-lg-4 { + order: 4 !important; + } + .order-lg-5 { + order: 5 !important; + } + .order-lg-last { + order: 6 !important; + } + .m-lg-0 { + margin: 0 !important; + } + .m-lg-1 { + margin: 0.25rem !important; + } + .m-lg-2 { + margin: 0.5rem !important; + } + .m-lg-3 { + margin: 1rem !important; + } + .m-lg-4 { + margin: 1.5rem !important; + } + .m-lg-5 { + margin: 3rem !important; + } + .m-lg-auto { + margin: auto !important; + } + .mx-lg-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + .mx-lg-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-lg-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + .mx-lg-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + .mx-lg-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + .mx-lg-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + .mx-lg-auto { + margin-right: auto !important; + margin-left: auto !important; + } + .my-lg-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-lg-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-lg-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-lg-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-lg-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-lg-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-lg-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-lg-0 { + margin-top: 0 !important; + } + .mt-lg-1 { + margin-top: 0.25rem !important; + } + .mt-lg-2 { + margin-top: 0.5rem !important; + } + .mt-lg-3 { + margin-top: 1rem !important; + } + .mt-lg-4 { + margin-top: 1.5rem !important; + } + .mt-lg-5 { + margin-top: 3rem !important; + } + .mt-lg-auto { + margin-top: auto !important; + } + .me-lg-0 { + margin-right: 0 !important; + } + .me-lg-1 { + margin-right: 0.25rem !important; + } + .me-lg-2 { + margin-right: 0.5rem !important; + } + .me-lg-3 { + margin-right: 1rem !important; + } + .me-lg-4 { + margin-right: 1.5rem !important; + } + .me-lg-5 { + margin-right: 3rem !important; + } + .me-lg-auto { + margin-right: auto !important; + } + .mb-lg-0 { + margin-bottom: 0 !important; + } + .mb-lg-1 { + margin-bottom: 0.25rem !important; + } + .mb-lg-2 { + margin-bottom: 0.5rem !important; + } + .mb-lg-3 { + margin-bottom: 1rem !important; + } + .mb-lg-4 { + margin-bottom: 1.5rem !important; + } + .mb-lg-5 { + margin-bottom: 3rem !important; + } + .mb-lg-auto { + margin-bottom: auto !important; + } + .ms-lg-0 { + margin-left: 0 !important; + } + .ms-lg-1 { + margin-left: 0.25rem !important; + } + .ms-lg-2 { + margin-left: 0.5rem !important; + } + .ms-lg-3 { + margin-left: 1rem !important; + } + .ms-lg-4 { + margin-left: 1.5rem !important; + } + .ms-lg-5 { + margin-left: 3rem !important; + } + .ms-lg-auto { + margin-left: auto !important; + } + .p-lg-0 { + padding: 0 !important; + } + .p-lg-1 { + padding: 0.25rem !important; + } + .p-lg-2 { + padding: 0.5rem !important; + } + .p-lg-3 { + padding: 1rem !important; + } + .p-lg-4 { + padding: 1.5rem !important; + } + .p-lg-5 { + padding: 3rem !important; + } + .px-lg-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + .px-lg-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-lg-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + .px-lg-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + .px-lg-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + .px-lg-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + .py-lg-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-lg-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-lg-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-lg-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-lg-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-lg-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-lg-0 { + padding-top: 0 !important; + } + .pt-lg-1 { + padding-top: 0.25rem !important; + } + .pt-lg-2 { + padding-top: 0.5rem !important; + } + .pt-lg-3 { + padding-top: 1rem !important; + } + .pt-lg-4 { + padding-top: 1.5rem !important; + } + .pt-lg-5 { + padding-top: 3rem !important; + } + .pe-lg-0 { + padding-right: 0 !important; + } + .pe-lg-1 { + padding-right: 0.25rem !important; + } + .pe-lg-2 { + padding-right: 0.5rem !important; + } + .pe-lg-3 { + padding-right: 1rem !important; + } + .pe-lg-4 { + padding-right: 1.5rem !important; + } + .pe-lg-5 { + padding-right: 3rem !important; + } + .pb-lg-0 { + padding-bottom: 0 !important; + } + .pb-lg-1 { + padding-bottom: 0.25rem !important; + } + .pb-lg-2 { + padding-bottom: 0.5rem !important; + } + .pb-lg-3 { + padding-bottom: 1rem !important; + } + .pb-lg-4 { + padding-bottom: 1.5rem !important; + } + .pb-lg-5 { + padding-bottom: 3rem !important; + } + .ps-lg-0 { + padding-left: 0 !important; + } + .ps-lg-1 { + padding-left: 0.25rem !important; + } + .ps-lg-2 { + padding-left: 0.5rem !important; + } + .ps-lg-3 { + padding-left: 1rem !important; + } + .ps-lg-4 { + padding-left: 1.5rem !important; + } + .ps-lg-5 { + padding-left: 3rem !important; + } +} +@media (min-width: 1200px) { + .d-xl-inline { + display: inline !important; + } + .d-xl-inline-block { + display: inline-block !important; + } + .d-xl-block { + display: block !important; + } + .d-xl-grid { + display: grid !important; + } + .d-xl-inline-grid { + display: inline-grid !important; + } + .d-xl-table { + display: table !important; + } + .d-xl-table-row { + display: table-row !important; + } + .d-xl-table-cell { + display: table-cell !important; + } + .d-xl-flex { + display: flex !important; + } + .d-xl-inline-flex { + display: inline-flex !important; + } + .d-xl-none { + display: none !important; + } + .flex-xl-fill { + flex: 1 1 auto !important; + } + .flex-xl-row { + flex-direction: row !important; + } + .flex-xl-column { + flex-direction: column !important; + } + .flex-xl-row-reverse { + flex-direction: row-reverse !important; + } + .flex-xl-column-reverse { + flex-direction: column-reverse !important; + } + .flex-xl-grow-0 { + flex-grow: 0 !important; + } + .flex-xl-grow-1 { + flex-grow: 1 !important; + } + .flex-xl-shrink-0 { + flex-shrink: 0 !important; + } + .flex-xl-shrink-1 { + flex-shrink: 1 !important; + } + .flex-xl-wrap { + flex-wrap: wrap !important; + } + .flex-xl-nowrap { + flex-wrap: nowrap !important; + } + .flex-xl-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-xl-start { + justify-content: flex-start !important; + } + .justify-content-xl-end { + justify-content: flex-end !important; + } + .justify-content-xl-center { + justify-content: center !important; + } + .justify-content-xl-between { + justify-content: space-between !important; + } + .justify-content-xl-around { + justify-content: space-around !important; + } + .justify-content-xl-evenly { + justify-content: space-evenly !important; + } + .align-items-xl-start { + align-items: flex-start !important; + } + .align-items-xl-end { + align-items: flex-end !important; + } + .align-items-xl-center { + align-items: center !important; + } + .align-items-xl-baseline { + align-items: baseline !important; + } + .align-items-xl-stretch { + align-items: stretch !important; + } + .align-content-xl-start { + align-content: flex-start !important; + } + .align-content-xl-end { + align-content: flex-end !important; + } + .align-content-xl-center { + align-content: center !important; + } + .align-content-xl-between { + align-content: space-between !important; + } + .align-content-xl-around { + align-content: space-around !important; + } + .align-content-xl-stretch { + align-content: stretch !important; + } + .align-self-xl-auto { + align-self: auto !important; + } + .align-self-xl-start { + align-self: flex-start !important; + } + .align-self-xl-end { + align-self: flex-end !important; + } + .align-self-xl-center { + align-self: center !important; + } + .align-self-xl-baseline { + align-self: baseline !important; + } + .align-self-xl-stretch { + align-self: stretch !important; + } + .order-xl-first { + order: -1 !important; + } + .order-xl-0 { + order: 0 !important; + } + .order-xl-1 { + order: 1 !important; + } + .order-xl-2 { + order: 2 !important; + } + .order-xl-3 { + order: 3 !important; + } + .order-xl-4 { + order: 4 !important; + } + .order-xl-5 { + order: 5 !important; + } + .order-xl-last { + order: 6 !important; + } + .m-xl-0 { + margin: 0 !important; + } + .m-xl-1 { + margin: 0.25rem !important; + } + .m-xl-2 { + margin: 0.5rem !important; + } + .m-xl-3 { + margin: 1rem !important; + } + .m-xl-4 { + margin: 1.5rem !important; + } + .m-xl-5 { + margin: 3rem !important; + } + .m-xl-auto { + margin: auto !important; + } + .mx-xl-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + .mx-xl-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-xl-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + .mx-xl-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + .mx-xl-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + .mx-xl-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + .mx-xl-auto { + margin-right: auto !important; + margin-left: auto !important; + } + .my-xl-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-xl-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-xl-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-xl-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-xl-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-xl-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-xl-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-xl-0 { + margin-top: 0 !important; + } + .mt-xl-1 { + margin-top: 0.25rem !important; + } + .mt-xl-2 { + margin-top: 0.5rem !important; + } + .mt-xl-3 { + margin-top: 1rem !important; + } + .mt-xl-4 { + margin-top: 1.5rem !important; + } + .mt-xl-5 { + margin-top: 3rem !important; + } + .mt-xl-auto { + margin-top: auto !important; + } + .me-xl-0 { + margin-right: 0 !important; + } + .me-xl-1 { + margin-right: 0.25rem !important; + } + .me-xl-2 { + margin-right: 0.5rem !important; + } + .me-xl-3 { + margin-right: 1rem !important; + } + .me-xl-4 { + margin-right: 1.5rem !important; + } + .me-xl-5 { + margin-right: 3rem !important; + } + .me-xl-auto { + margin-right: auto !important; + } + .mb-xl-0 { + margin-bottom: 0 !important; + } + .mb-xl-1 { + margin-bottom: 0.25rem !important; + } + .mb-xl-2 { + margin-bottom: 0.5rem !important; + } + .mb-xl-3 { + margin-bottom: 1rem !important; + } + .mb-xl-4 { + margin-bottom: 1.5rem !important; + } + .mb-xl-5 { + margin-bottom: 3rem !important; + } + .mb-xl-auto { + margin-bottom: auto !important; + } + .ms-xl-0 { + margin-left: 0 !important; + } + .ms-xl-1 { + margin-left: 0.25rem !important; + } + .ms-xl-2 { + margin-left: 0.5rem !important; + } + .ms-xl-3 { + margin-left: 1rem !important; + } + .ms-xl-4 { + margin-left: 1.5rem !important; + } + .ms-xl-5 { + margin-left: 3rem !important; + } + .ms-xl-auto { + margin-left: auto !important; + } + .p-xl-0 { + padding: 0 !important; + } + .p-xl-1 { + padding: 0.25rem !important; + } + .p-xl-2 { + padding: 0.5rem !important; + } + .p-xl-3 { + padding: 1rem !important; + } + .p-xl-4 { + padding: 1.5rem !important; + } + .p-xl-5 { + padding: 3rem !important; + } + .px-xl-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + .px-xl-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-xl-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + .px-xl-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + .px-xl-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + .px-xl-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + .py-xl-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-xl-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-xl-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-xl-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-xl-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-xl-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-xl-0 { + padding-top: 0 !important; + } + .pt-xl-1 { + padding-top: 0.25rem !important; + } + .pt-xl-2 { + padding-top: 0.5rem !important; + } + .pt-xl-3 { + padding-top: 1rem !important; + } + .pt-xl-4 { + padding-top: 1.5rem !important; + } + .pt-xl-5 { + padding-top: 3rem !important; + } + .pe-xl-0 { + padding-right: 0 !important; + } + .pe-xl-1 { + padding-right: 0.25rem !important; + } + .pe-xl-2 { + padding-right: 0.5rem !important; + } + .pe-xl-3 { + padding-right: 1rem !important; + } + .pe-xl-4 { + padding-right: 1.5rem !important; + } + .pe-xl-5 { + padding-right: 3rem !important; + } + .pb-xl-0 { + padding-bottom: 0 !important; + } + .pb-xl-1 { + padding-bottom: 0.25rem !important; + } + .pb-xl-2 { + padding-bottom: 0.5rem !important; + } + .pb-xl-3 { + padding-bottom: 1rem !important; + } + .pb-xl-4 { + padding-bottom: 1.5rem !important; + } + .pb-xl-5 { + padding-bottom: 3rem !important; + } + .ps-xl-0 { + padding-left: 0 !important; + } + .ps-xl-1 { + padding-left: 0.25rem !important; + } + .ps-xl-2 { + padding-left: 0.5rem !important; + } + .ps-xl-3 { + padding-left: 1rem !important; + } + .ps-xl-4 { + padding-left: 1.5rem !important; + } + .ps-xl-5 { + padding-left: 3rem !important; + } +} +@media (min-width: 1400px) { + .d-xxl-inline { + display: inline !important; + } + .d-xxl-inline-block { + display: inline-block !important; + } + .d-xxl-block { + display: block !important; + } + .d-xxl-grid { + display: grid !important; + } + .d-xxl-inline-grid { + display: inline-grid !important; + } + .d-xxl-table { + display: table !important; + } + .d-xxl-table-row { + display: table-row !important; + } + .d-xxl-table-cell { + display: table-cell !important; + } + .d-xxl-flex { + display: flex !important; + } + .d-xxl-inline-flex { + display: inline-flex !important; + } + .d-xxl-none { + display: none !important; + } + .flex-xxl-fill { + flex: 1 1 auto !important; + } + .flex-xxl-row { + flex-direction: row !important; + } + .flex-xxl-column { + flex-direction: column !important; + } + .flex-xxl-row-reverse { + flex-direction: row-reverse !important; + } + .flex-xxl-column-reverse { + flex-direction: column-reverse !important; + } + .flex-xxl-grow-0 { + flex-grow: 0 !important; + } + .flex-xxl-grow-1 { + flex-grow: 1 !important; + } + .flex-xxl-shrink-0 { + flex-shrink: 0 !important; + } + .flex-xxl-shrink-1 { + flex-shrink: 1 !important; + } + .flex-xxl-wrap { + flex-wrap: wrap !important; + } + .flex-xxl-nowrap { + flex-wrap: nowrap !important; + } + .flex-xxl-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-xxl-start { + justify-content: flex-start !important; + } + .justify-content-xxl-end { + justify-content: flex-end !important; + } + .justify-content-xxl-center { + justify-content: center !important; + } + .justify-content-xxl-between { + justify-content: space-between !important; + } + .justify-content-xxl-around { + justify-content: space-around !important; + } + .justify-content-xxl-evenly { + justify-content: space-evenly !important; + } + .align-items-xxl-start { + align-items: flex-start !important; + } + .align-items-xxl-end { + align-items: flex-end !important; + } + .align-items-xxl-center { + align-items: center !important; + } + .align-items-xxl-baseline { + align-items: baseline !important; + } + .align-items-xxl-stretch { + align-items: stretch !important; + } + .align-content-xxl-start { + align-content: flex-start !important; + } + .align-content-xxl-end { + align-content: flex-end !important; + } + .align-content-xxl-center { + align-content: center !important; + } + .align-content-xxl-between { + align-content: space-between !important; + } + .align-content-xxl-around { + align-content: space-around !important; + } + .align-content-xxl-stretch { + align-content: stretch !important; + } + .align-self-xxl-auto { + align-self: auto !important; + } + .align-self-xxl-start { + align-self: flex-start !important; + } + .align-self-xxl-end { + align-self: flex-end !important; + } + .align-self-xxl-center { + align-self: center !important; + } + .align-self-xxl-baseline { + align-self: baseline !important; + } + .align-self-xxl-stretch { + align-self: stretch !important; + } + .order-xxl-first { + order: -1 !important; + } + .order-xxl-0 { + order: 0 !important; + } + .order-xxl-1 { + order: 1 !important; + } + .order-xxl-2 { + order: 2 !important; + } + .order-xxl-3 { + order: 3 !important; + } + .order-xxl-4 { + order: 4 !important; + } + .order-xxl-5 { + order: 5 !important; + } + .order-xxl-last { + order: 6 !important; + } + .m-xxl-0 { + margin: 0 !important; + } + .m-xxl-1 { + margin: 0.25rem !important; + } + .m-xxl-2 { + margin: 0.5rem !important; + } + .m-xxl-3 { + margin: 1rem !important; + } + .m-xxl-4 { + margin: 1.5rem !important; + } + .m-xxl-5 { + margin: 3rem !important; + } + .m-xxl-auto { + margin: auto !important; + } + .mx-xxl-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + .mx-xxl-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-xxl-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + .mx-xxl-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + .mx-xxl-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + .mx-xxl-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + .mx-xxl-auto { + margin-right: auto !important; + margin-left: auto !important; + } + .my-xxl-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-xxl-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-xxl-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-xxl-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-xxl-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-xxl-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-xxl-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-xxl-0 { + margin-top: 0 !important; + } + .mt-xxl-1 { + margin-top: 0.25rem !important; + } + .mt-xxl-2 { + margin-top: 0.5rem !important; + } + .mt-xxl-3 { + margin-top: 1rem !important; + } + .mt-xxl-4 { + margin-top: 1.5rem !important; + } + .mt-xxl-5 { + margin-top: 3rem !important; + } + .mt-xxl-auto { + margin-top: auto !important; + } + .me-xxl-0 { + margin-right: 0 !important; + } + .me-xxl-1 { + margin-right: 0.25rem !important; + } + .me-xxl-2 { + margin-right: 0.5rem !important; + } + .me-xxl-3 { + margin-right: 1rem !important; + } + .me-xxl-4 { + margin-right: 1.5rem !important; + } + .me-xxl-5 { + margin-right: 3rem !important; + } + .me-xxl-auto { + margin-right: auto !important; + } + .mb-xxl-0 { + margin-bottom: 0 !important; + } + .mb-xxl-1 { + margin-bottom: 0.25rem !important; + } + .mb-xxl-2 { + margin-bottom: 0.5rem !important; + } + .mb-xxl-3 { + margin-bottom: 1rem !important; + } + .mb-xxl-4 { + margin-bottom: 1.5rem !important; + } + .mb-xxl-5 { + margin-bottom: 3rem !important; + } + .mb-xxl-auto { + margin-bottom: auto !important; + } + .ms-xxl-0 { + margin-left: 0 !important; + } + .ms-xxl-1 { + margin-left: 0.25rem !important; + } + .ms-xxl-2 { + margin-left: 0.5rem !important; + } + .ms-xxl-3 { + margin-left: 1rem !important; + } + .ms-xxl-4 { + margin-left: 1.5rem !important; + } + .ms-xxl-5 { + margin-left: 3rem !important; + } + .ms-xxl-auto { + margin-left: auto !important; + } + .p-xxl-0 { + padding: 0 !important; + } + .p-xxl-1 { + padding: 0.25rem !important; + } + .p-xxl-2 { + padding: 0.5rem !important; + } + .p-xxl-3 { + padding: 1rem !important; + } + .p-xxl-4 { + padding: 1.5rem !important; + } + .p-xxl-5 { + padding: 3rem !important; + } + .px-xxl-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + .px-xxl-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-xxl-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + .px-xxl-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + .px-xxl-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + .px-xxl-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + .py-xxl-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-xxl-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-xxl-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-xxl-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-xxl-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-xxl-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-xxl-0 { + padding-top: 0 !important; + } + .pt-xxl-1 { + padding-top: 0.25rem !important; + } + .pt-xxl-2 { + padding-top: 0.5rem !important; + } + .pt-xxl-3 { + padding-top: 1rem !important; + } + .pt-xxl-4 { + padding-top: 1.5rem !important; + } + .pt-xxl-5 { + padding-top: 3rem !important; + } + .pe-xxl-0 { + padding-right: 0 !important; + } + .pe-xxl-1 { + padding-right: 0.25rem !important; + } + .pe-xxl-2 { + padding-right: 0.5rem !important; + } + .pe-xxl-3 { + padding-right: 1rem !important; + } + .pe-xxl-4 { + padding-right: 1.5rem !important; + } + .pe-xxl-5 { + padding-right: 3rem !important; + } + .pb-xxl-0 { + padding-bottom: 0 !important; + } + .pb-xxl-1 { + padding-bottom: 0.25rem !important; + } + .pb-xxl-2 { + padding-bottom: 0.5rem !important; + } + .pb-xxl-3 { + padding-bottom: 1rem !important; + } + .pb-xxl-4 { + padding-bottom: 1.5rem !important; + } + .pb-xxl-5 { + padding-bottom: 3rem !important; + } + .ps-xxl-0 { + padding-left: 0 !important; + } + .ps-xxl-1 { + padding-left: 0.25rem !important; + } + .ps-xxl-2 { + padding-left: 0.5rem !important; + } + .ps-xxl-3 { + padding-left: 1rem !important; + } + .ps-xxl-4 { + padding-left: 1.5rem !important; + } + .ps-xxl-5 { + padding-left: 3rem !important; + } +} +@media print { + .d-print-inline { + display: inline !important; + } + .d-print-inline-block { + display: inline-block !important; + } + .d-print-block { + display: block !important; + } + .d-print-grid { + display: grid !important; + } + .d-print-inline-grid { + display: inline-grid !important; + } + .d-print-table { + display: table !important; + } + .d-print-table-row { + display: table-row !important; + } + .d-print-table-cell { + display: table-cell !important; + } + .d-print-flex { + display: flex !important; + } + .d-print-inline-flex { + display: inline-flex !important; + } + .d-print-none { + display: none !important; + } +} + +/*# sourceMappingURL=bootstrap-grid.css.map */ \ No newline at end of file diff --git a/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map new file mode 100644 index 0000000..ce99ec1 --- /dev/null +++ b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","bootstrap-grid.css","../../scss/mixins/_breakpoints.scss","../../scss/_variables.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"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EE,mBAAA;EJ6+BR;EI1+BM;;IAEE,mBAAA;EJ4+BR;AACF;AKpiCQ;EAOI,0BAAA;ALgiCZ;;AKviCQ;EAOI,gCAAA;ALoiCZ;;AK3iCQ;EAOI,yBAAA;ALwiCZ;;AK/iCQ;EAOI,wBAAA;AL4iCZ;;AKnjCQ;EAOI,+BAAA;ALgjCZ;;AKvjCQ;EAOI,yBAAA;ALojCZ;;AK3jCQ;EAOI,6BAAA;ALwjCZ;;AK/jCQ;EAOI,8BAAA;AL4jCZ;;AKnkCQ;EAOI,wBAAA;ALgkCZ;;AKvkCQ;EAOI,+BAAA;ALokCZ;;AK3kCQ;EAOI,wBAAA;ALwkCZ;;AK/kCQ;EAOI,yBAAA;AL4kCZ;;AKnlCQ;EAOI,8BAAA;ALglCZ;;AKvlCQ;EAOI,iCAAA;ALolCZ;;AK3lCQ;EAOI,sCAAA;ALwlCZ;;AK/lCQ;EAOI,yCAAA;AL4lCZ;;AKnmCQ;EAOI,uBAAA;ALgmCZ;;AKvmCQ;EAOI,uBAAA;ALomCZ;;AK3mCQ;EAOI,yBAAA;ALwmCZ;;AK/mCQ;EAOI,yBAAA;AL4mCZ;;AKnnCQ;EAOI,0BAAA;ALgnCZ;;AKvnCQ;EAOI,4BAAA;ALonCZ;;AK3nCQ;EAOI,kCAAA;ALwnCZ;;AK/nCQ;EAOI,sCAAA;AL4nCZ;;AKnoCQ;EAOI,oCAAA;ALgoCZ;;AKvoCQ;EAOI,kCAAA;ALooCZ;;AK3oCQ;EAOI,yCAAA;ALwoCZ;;AK/oCQ;EAOI,wCAAA;AL4oCZ;;AKnpCQ;EAOI,wCAAA;ALgpCZ;;AKvpCQ;EAOI,kCAAA;ALopCZ;;AK3pCQ;EAOI,gCAAA;ALwpCZ;;AK/pCQ;EAOI,8BAAA;AL4pCZ;;AKnqCQ;EAOI,gCAAA;ALgqCZ;;AKvqCQ;EAOI,+BAAA;ALoqCZ;;AK3qCQ;EAOI,oCAAA;ALwqCZ;;AK/qCQ;EAOI,kCAAA;AL4qCZ;;AKnrCQ;EAOI,gCAAA;ALgrCZ;;AKvrCQ;EAOI,uCAAA;ALorCZ;;AK3rCQ;EAOI,sCAAA;ALwrCZ;;AK/rCQ;EAOI,iCAAA;AL4rCZ;;AKnsCQ;EAOI,2BAAA;ALgsCZ;;AKvsCQ;EAOI,iCAAA;ALosCZ;;AK3sCQ;EAOI,+BAAA;ALwsCZ;;AK/sCQ;EAOI,6BAAA;AL4sCZ;;AKntCQ;EA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;AL8iDZ;;AKrjDQ;EAOI,4BAAA;ALkjDZ;;AKzjDQ;EAOI,2BAAA;ALsjDZ;;AK7jDQ;EAOI,iCAAA;AL0jDZ;;AKjkDQ;EAOI,gCAAA;AL8jDZ;;AKrkDQ;EAOI,8BAAA;ALkkDZ;;AKzkDQ;EAOI,gCAAA;ALskDZ;;AK7kDQ;EAOI,8BAAA;AL0kDZ;;AKjlDQ;EAOI,4BAAA;AL8kDZ;;AKrlDQ;EAOI,kCAAA;ALklDZ;;AKzlDQ;EAOI,iCAAA;ALslDZ;;AK7lDQ;EAOI,+BAAA;AL0lDZ;;AKjmDQ;EAOI,iCAAA;AL8lDZ;;AKrmDQ;EAOI,+BAAA;ALkmDZ;;AKzmDQ;EAOI,0BAAA;ALsmDZ;;AK7mDQ;EAOI,gCAAA;AL0mDZ;;AKjnDQ;EAOI,+BAAA;AL8mDZ;;AKrnDQ;EAOI,6BAAA;ALknDZ;;AKznDQ;EAOI,+BAAA;ALsnDZ;;AK7nDQ;EAOI,6BAAA;AL0nDZ;;ACpoDI;EIGI;IAOI,0BAAA;EL+nDV;EKtoDM;IAOI,gCAAA;ELkoDV;EKzoDM;IAOI,yBAAA;ELqoDV;EK5oDM;IAOI,wBAAA;ELwoDV;EK/oDM;IAOI,+BAAA;EL2oDV;EKlpDM;IAOI,yBAAA;EL8oDV;EKrpDM;IAOI,6BAAA;ELipDV;EKxpDM;IAOI,8BAAA;ELopDV;EK3pDM;IAOI,wBAAA;ELupDV;EK9pDM;IAOI,+BAAA;EL0pDV;EKjqDM;IAOI,wBAAA;EL6pDV;EKpqDM;IAOI,yBAAA;ELgqDV;EKvqDM;IAOI,8BAAA;ELmqDV;EK1qDM;IAOI,iCAAA;ELsqDV;EK7qDM;IAOI,sCAAA;ELyqDV;EKhrDM;IAOI,yCAAA;EL4qDV;EKnrDM;IAOI,uBAAA;EL+qDV;EKtrDM;IAOI,uBAAA;ELkrDV;EKzrDM;IAOI,yBAAA;ELqrDV;EK5rDM;IAOI,yBAAA;ELwrDV;EK/rDM;IAOI,0BAAA;EL2rDV;EKlsDM;IAOI,4BAAA;EL8rDV;EKrsDM;IAOI,kCAAA;ELisDV;EKxsDM;IAOI,sCAAA;ELosDV;EK3sDM;IAOI,oCAAA;ELusDV;EK9sDM;IAOI,kCAAA;EL0sDV;EKjtDM;IAOI,yCAAA;EL6sDV;EKptDM;IAOI,wCAAA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nEV;EK/nEM;IAOI,yCAAA;EL2nEV;EKloEM;IAOI,uBAAA;EL8nEV;EKroEM;IAOI,uBAAA;ELioEV;EKxoEM;IAOI,yBAAA;ELooEV;EK3oEM;IAOI,yBAAA;ELuoEV;EK9oEM;IAOI,0BAAA;EL0oEV;EKjpEM;IAOI,4BAAA;EL6oEV;EKppEM;IAOI,kCAAA;ELgpEV;EKvpEM;IAOI,sCAAA;ELmpEV;EK1pEM;IAOI,oCAAA;ELspEV;EK7pEM;IAOI,kCAAA;ELypEV;EKhqEM;IAOI,yCAAA;EL4pEV;EKnqEM;IAOI,wCAAA;EL+pEV;EKtqEM;IAOI,wCAAA;ELkqEV;EKzqEM;IAOI,kCAAA;ELqqEV;EK5qEM;IAOI,gCAAA;ELwqEV;EK/qEM;IAOI,8BAAA;EL2qEV;EKlrEM;IAOI,gCAAA;EL8qEV;EKrrEM;IAOI,+BAAA;ELirEV;EKxrEM;IAOI,oCAAA;ELorEV;EK3rEM;IAOI,kCAAA;ELurEV;EK9rEM;IAOI,gCAAA;EL0rEV;EKjsEM;IAOI,uCAAA;EL6rEV;EKpsEM;IAOI,sCAAA;ELgsEV;EKvsEM;IAOI,iCAAA;ELmsEV;EK1sEM;IAOI,2BAAA;ELssEV;EK7sEM;IAOI,iCAAA;ELysEV;EKhtEM;IAOI,+BAAA;EL4sEV;EKntEM;IAOI,6BAAA;EL+sEV;EKttEM;IAOI,+BAAA;ELktEV;EKztEM;IAOI,8BAAA;ELqtEV;EK5tEM;IAOI,oBAAA;ELwtEV;EK/tEM;IAOI,mBAAA;EL2tEV;EKluEM;IAOI,mBAAA;EL8tEV;EKruEM;IAOI,mBAAA;ELiuEV;EKxuEM;IAOI,mBAAA;ELouEV;EK3uEM;IAOI,mBAAA;ELuuEV;EK9uEM;IAOI,mBAAA;EL0uEV;EKjvEM;IAOI,mBAAA;EL6uEV;EKpvEM;IAOI,oBAAA;ELgvEV;EKvvEM;IAOI,0BAAA;ELmvEV;EK1vEM;IAOI,yBAAA;ELsvEV;EK7vEM;IAOI,uBAAA;ELyvEV;EKhwEM;IAOI,yBAAA;EL4vEV;EKnwEM;IAOI,uBAAA;EL+vEV;EKtwEM;IAOI,uBAAA;ELkwEV;EKzwEM;IAOI,0BAAA;IAAA,yBAAA;ELswEV;EK7wEM;IAO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EKviFM;IAOI,yBAAA;ELmiFV;EK1iFM;IAOI,wBAAA;ELsiFV;EK7iFM;IAOI,+BAAA;ELyiFV;EKhjFM;IAOI,yBAAA;EL4iFV;EKnjFM;IAOI,6BAAA;EL+iFV;EKtjFM;IAOI,8BAAA;ELkjFV;EKzjFM;IAOI,wBAAA;ELqjFV;EK5jFM;IAOI,+BAAA;ELwjFV;EK/jFM;IAOI,wBAAA;EL2jFV;EKlkFM;IAOI,yBAAA;EL8jFV;EKrkFM;IAOI,8BAAA;ELikFV;EKxkFM;IAOI,iCAAA;ELokFV;EK3kFM;IAOI,sCAAA;ELukFV;EK9kFM;IAOI,yCAAA;EL0kFV;EKjlFM;IAOI,uBAAA;EL6kFV;EKplFM;IAOI,uBAAA;ELglFV;EKvlFM;IAOI,yBAAA;ELmlFV;EK1lFM;IAOI,yBAAA;ELslFV;EK7lFM;IAOI,0BAAA;ELylFV;EKhmFM;IAOI,4BAAA;EL4lFV;EKnmFM;IAOI,kCAAA;EL+lFV;EKtmFM;IAOI,sCAAA;ELkmFV;EKzmFM;IAOI,oCAAA;ELqmFV;EK5mFM;IAOI,kCAAA;ELwmFV;EK/mFM;IAOI,yCAAA;EL2mFV;EKlnFM;IAOI,wCAAA;EL8mFV;EKrnFM;IAOI,wCAAA;ELinFV;EKxnFM;IAOI,kCAAA;ELonFV;EK3nFM;IAOI,gCAAA;ELunFV;EK9nFM;IAOI,8BAAA;EL0nFV;EKjoFM;IAOI,gCAAA;EL6nFV;EKpoFM;IAOI,+BAAA;ELgoFV;EKvoFM;IAOI,oCAAA;ELmoFV;EK1oFM;IAOI,kCAAA;ELsoFV;EK7oFM;IAOI,gCAAA;ELyoFV;EKhpFM;IAOI,uCAAA;EL4oFV;EKnpFM;IAOI,sCAAA;EL+oFV;EKtpFM;IAOI,iCAAA;ELkpFV;EKzpFM;IAOI,2BAAA;ELqpFV;EK5pFM;IAOI,iCAAA;ELwpFV;EK/pFM;IAOI,+BAAA;EL2pFV;EKlqFM;IAOI,6BAAA;EL8pFV;EKrqFM;IAOI,+BAAA;ELiqFV;EKxqFM;IAOI,8BAAA;ELoqFV;EK3qFM;IAOI,oBAAA;ELuqFV;EK9qFM;IAOI,mBAAA;EL0qFV;EKjrFM;IAOI,mBAAA;EL6qFV;EKprFM;IAOI,mBAAA;ELgrFV;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AAA;ELy8FV;EKh9FM;IAOI,iCAAA;EL48FV;EKn9FM;IAOI,+BAAA;EL+8FV;EKt9FM;IAOI,iCAAA;ELk9FV;EKz9FM;IAOI,+BAAA;ELq9FV;EK59FM;IAOI,0BAAA;ELw9FV;EK/9FM;IAOI,gCAAA;EL29FV;EKl+FM;IAOI,+BAAA;EL89FV;EKr+FM;IAOI,6BAAA;ELi+FV;EKx+FM;IAOI,+BAAA;ELo+FV;EK3+FM;IAOI,6BAAA;ELu+FV;AACF;ACl/FI;EIGI;IAOI,0BAAA;EL4+FV;EKn/FM;IAOI,gCAAA;EL++FV;EKt/FM;IAOI,yBAAA;ELk/FV;EKz/FM;IAOI,wBAAA;ELq/FV;EK5/FM;IAOI,+BAAA;ELw/FV;EK//FM;IAOI,yBAAA;EL2/FV;EKlgGM;IAOI,6BAAA;EL8/FV;EKrgGM;IAOI,8BAAA;ELigGV;EKxgGM;IAOI,wBAAA;ELogGV;EK3gGM;IAOI,+BAAA;ELugGV;EK9gGM;IAOI,wBAAA;EL0gGV;EKjhGM;IAOI,yBAAA;EL6gGV;EKphGM;IAOI,8BAAA;ELghGV;EKvhGM;IAOI,iCAAA;ELmhGV;EK1hGM;IAOI,sCAAA;ELshGV;EK7hGM;IAOI,yCAAA;ELyhGV;EKhiGM;IAOI,uBAAA;EL4hGV;EKniGM;IAOI,uBAAA;EL+hGV;EKtiGM;IAOI,yBAAA;ELkiGV;EKziGM;IAOI,yBAAA;ELqiGV;EK5iGM;IAOI,0BAAA;ELwiGV;EK/iGM;IAOI,4BAAA;EL2iGV;EKljGM;IAOI,kCAAA;EL8iGV;EKrjGM;IAOI,sCAAA;ELijGV;EKxjGM;IAOI,oCAAA;ELojGV;EK3jGM;IAOI,kCAAA;ELujGV;EK9jGM;IAOI,yCAAA;EL0jGV;EKjkGM;IAOI,wCAAA;EL6jGV;EKpkGM;IAOI,wCAAA;ELgkGV;EKvkGM;IAOI,kCAAA;ELmkGV;EK1kGM;IAOI,gCAAA;ELskGV;EK7kGM;IAOI,8BAAA;ELykGV;EKhlGM;IAOI,gCAAA;EL4kGV;EKnlGM;IAOI,+BAAA;EL+kGV;EKtlGM;IAOI,oCAAA;ELklGV;EKzlGM;IAOI,kCAAA;ELqlGV;EK5lGM;IAOI,gCAAA;ELwlGV;EK/l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ELi3GV;EKx3GM;IAOI,+BAAA;ELo3GV;EK33GM;IAOI,8BAAA;ELu3GV;EK93GM;IAOI,4BAAA;EL03GV;EKj4GM;IAOI,8BAAA;EL63GV;EKp4GM;IAOI,4BAAA;ELg4GV;EKv4GM;IAOI,2BAAA;ELm4GV;EK14GM;IAOI,iCAAA;ELs4GV;EK74GM;IAOI,gCAAA;ELy4GV;EKh5GM;IAOI,8BAAA;EL44GV;EKn5GM;IAOI,gCAAA;EL+4GV;EKt5GM;IAOI,8BAAA;ELk5GV;EKz5GM;IAOI,4BAAA;ELq5GV;EK55GM;IAOI,kCAAA;ELw5GV;EK/5GM;IAOI,iCAAA;EL25GV;EKl6GM;IAOI,+BAAA;EL85GV;EKr6GM;IAOI,iCAAA;ELi6GV;EKx6GM;IAOI,+BAAA;ELo6GV;EK36GM;IAOI,0BAAA;ELu6GV;EK96GM;IAOI,gCAAA;EL06GV;EKj7GM;IAOI,+BAAA;EL66GV;EKp7GM;IAOI,6BAAA;ELg7GV;EKv7GM;IAOI,+BAAA;ELm7GV;EK17GM;IAOI,6BAAA;ELs7GV;AACF;ACj8GI;EIGI;IAOI,0BAAA;EL27GV;EKl8GM;IAOI,gCAAA;EL87GV;EKr8GM;IAOI,yBAAA;ELi8GV;EKx8GM;IAOI,wBAAA;ELo8GV;EK38GM;IAOI,+BAAA;ELu8GV;EK98GM;IAOI,yBAAA;EL08GV;EKj9GM;IAOI,6BAAA;EL68GV;EKp9GM;IAOI,8BAAA;ELg9GV;EKv9GM;IAOI,wBAAA;ELm9GV;EK19GM;IAOI,+BAAA;ELs9GV;EK79GM;IAOI,wBAAA;ELy9GV;EKh+GM;IAOI,yBAAA;EL49GV;EKn+GM;IAOI,8BAAA;EL+9GV;EKt+GM;IAOI,iCAAA;ELk+GV;EKz+GM;IAOI,sCAAA;ELq+GV;EK5+GM;IAOI,yCAAA;ELw+GV;EK/+GM;IAOI,uBAAA;EL2+GV;EKl/GM;IAOI,uBAAA;EL8+GV;EKr/GM;IAOI,yBAAA;ELi/GV;EKx/GM;IAOI,yBAAA;ELo/GV;EK3/GM;IAOI,0BAAA;ELu/GV;EK9/GM;IAOI,4BAAA;EL0/GV;EKjgHM;IAOI,kCAAA;EL6/GV;EKpgHM;IAOI,sCAAA;ELggHV;EKvgHM;IAOI,oCAAA;ELmgHV;EK1gHM;IAOI,kCAAA;ELsgHV;EK7gHM;IAOI,yCAAA;ELygHV;EKhhHM;IAOI,wCAAA;EL4gHV;EKnhHM;IAOI,wCAAA;EL+gHV;EKthHM;IAOI,kCAAA;ELkhHV;EKzhHM;IAOI,gCAAA;ELqhHV;EK5hHM;IAOI,8BAAA;ELwhHV;EK/hHM;IAOI,gCAAA;EL2hHV;EKliHM;IAOI,+BAAA;EL8hHV;EKriHM;IAOI,oCAAA;ELiiHV;EKxiHM;IAOI,kCAAA;ELoiHV;EK3iHM;IAOI,gCAAA;ELuiHV;EK9iHM;IAOI,uCAAA;EL0iHV;EKjjHM;IAOI,sCAAA;EL6iHV;EKpjHM;IAOI,iCAAA;ELgjHV;EKvjHM;IAOI,2BAAA;ELmjHV;EK1jHM;IAOI,iCAAA;ELsjHV;EK7jHM;IAOI,+BAAA;ELyjHV;EKhkHM;IAOI,6BAAA;EL4jHV;EKnkHM;IAOI,+BAAA;EL+jHV;EKtkHM;IAOI,8BAAA;ELkkHV;EKzkHM;IAOI,oBAAA;ELqkHV;EK5kHM;IAOI,mBAAA;ELwkHV;EK/kHM;IAOI,mBAAA;EL2kHV;EKllHM;IAOI,mBAAA;EL8kHV;EKrlHM;IAOI,mBAAA;ELilHV;EKxlHM;IAOI,mBAAA;ELolHV;EK3lHM;IAOI,mBAAA;ELulHV;EK9lHM;IAOI,mBAAA;EL0lHV;EKjmHM;IAOI,oBAAA;EL6lHV;EKpmHM;IAOI,0BAAA;ELgmHV;EKvmHM;IAOI,yBAAA;ELmmHV;EK1mHM;IAOI,uBAAA;ELsmHV;EK7mHM;IAOI,yBAAA;ELymHV;EKhnHM;IAOI,uBAAA;EL4mHV;EKnnHM;IAOI,uBAAA;EL+mHV;EKtnHM;IAOI,0BAAA;IAAA,yBAAA;ELmnHV;EK1nHM;IAOI,gCAAA;IAAA,+BAAA;ELunHV;EK9nHM;IAOI,+BAAA;IAAA,8BAAA;EL2nHV;EKloHM;IAOI,6BAAA;IAAA,4BAAA;EL+nHV;EKtoHM;IAOI,+BAAA;IAAA,8BAAA;ELmoHV;EK1oHM;IAOI,6BAAA;IAAA,4BAAA;ELuoHV;EK9oHM;IAOI,6BAAA;IAAA,4BAAA;EL2oHV;EKlpHM;IAOI,wBAAA;IAAA,2BAAA;EL+oHV;EKtpHM;IAOI,8BAAA;IAAA,iCAAA;ELmpHV;EK1pHM;IAOI,6BAAA;IAAA,gCAAA;ELupHV;EK9pHM;IAOI,2BAAA;IAAA,8BAAA;EL2pHV;EKlqHM;IAOI,6BAAA;IAAA,gCAAA;EL+pHV;EKtqHM;IAOI,2BAAA;IAAA,8BAAA;ELmqHV;EK1qHM;IAOI,2BAAA;IAAA,8BAAA;ELuqHV;EK9qHM;IAOI,wBAAA;EL0qHV;EKjrHM;IAOI,8BAAA;EL6qHV;EKprHM;IAOI,6BAAA;ELgrHV;EKvrHM;IAOI,2BAAA;ELmrHV;EK1rHM;IAOI,6BAAA;ELsrHV;EK7rHM;IAOI,2BAAA;ELyrHV;EKhsHM;IAOI,2BAAA;EL4rHV;EKnsHM;IAOI,0BAAA;EL+rHV;EKtsHM;IAOI,gCAAA;ELksHV;EKzsHM;IAOI,+BAAA;ELqsHV;EK5sHM;IAOI,6BAAA;ELwsHV;EK/sHM;IAOI,+BAAA;EL2sHV;EKltHM;IAOI,6BAAA;EL8sHV;EKrtHM;IAOI,6BAAA;ELitHV;EKxtHM;IAOI,2BAAA;ELotHV;EK3tHM;IAOI,iCAAA;ELutHV;EK9tHM;IAOI,gCAAA;EL0tHV;EKjuHM;IAOI,8BAAA;EL6tHV;EKpuHM;IAOI,gCAAA;ELguHV;EKvuHM;IAOI,8BAAA;ELmuHV;EK1uHM;IAOI,8BAAA;ELsuHV;EK7uHM;IAOI,yBAAA;ELyuHV;EKhvHM;IAOI,+BAAA;EL4uHV;EKnvHM;IAOI,8BAAA;EL+uHV;EKtvHM;IAOI,4BAAA;ELkvHV;EKzvHM;IAOI,8BAAA;ELqvHV;EK5vHM;IAOI,4BAAA;ELwvHV;EK/vHM;IAOI,4BAAA;EL2vHV;EKlwHM;IAOI,qBAAA;EL8vHV;EKrwHM;IAOI,2BAAA;ELiwHV;EKxwHM;IAOI,0BAAA;ELowHV;EK3wHM;IAOI,wBAAA;ELuwHV;EK9wHM;IAOI,0BAAA;EL0wHV;EKjxHM;IAOI,wBAAA;EL6wHV;EKpxHM;IAOI,2BAAA;IAAA,0BAAA;ELixHV;EKxxHM;IAOI,iCAAA;IAAA,gCAAA;ELqxHV;EK5xHM;IAOI,gCAAA;IAAA,+BAAA;ELyxHV;EKhyHM;IAOI,8BAAA;IAAA,6BAAA;EL6xHV;EKpyHM;IAOI,gCAAA;IAAA,+BAAA;ELiyHV;EKxyHM;IAOI,8BAAA;IAAA,6BAAA;ELqyHV;EK5yHM;IAOI,yBAAA;IAAA,4BAAA;ELyyHV;EKhzHM;IAOI,+BAAA;IAAA,kCAAA;EL6yHV;EKpzHM;IAOI,8BAAA;IAAA,iCAAA;ELizHV;EKxzHM;IAOI,4BAAA;IAAA,+BAAA;ELqzHV;EK5zHM;IAOI,8BAAA;IAAA,iCAAA;ELyzHV;EKh0HM;IAOI,4BAAA;IAAA,+BAAA;EL6zHV;EKp0HM;IAOI,yBAAA;ELg0HV;EKv0HM;IAOI,+BAAA;ELm0HV;EK10HM;IAOI,8BAAA;ELs0HV;EK70HM;IAOI,4BAAA;ELy0HV;EKh1HM;IAOI,8BAAA;EL40HV;EKn1HM;IAOI,4BAAA;EL+0HV;EKt1HM;IAOI,2BAAA;ELk1HV;EKz1HM;IAOI,iCAAA;ELq1HV;EK51HM;IAOI,gCAAA;ELw1HV;EK/1HM;IAOI,8BAAA;EL21HV;EKl2HM;IAOI,gCAAA;EL81HV;EKr2HM;IAOI,8BAAA;ELi2HV;EKx2HM;IAOI,4BAAA;ELo2HV;EK32HM;IAOI,kCAAA;ELu2HV;EK92HM;IAOI,iCAAA;EL02HV;EKj3HM;IAOI,+BAAA;EL62HV;EKp3HM;IAOI,iCAAA;ELg3HV;EKv3HM;IAOI,+BAAA;ELm3HV;EK13HM;IAOI,0BAAA;ELs3HV;EK73HM;IAOI,gCAAA;ELy3HV;EKh4HM;IAOI,+BAAA;EL43HV;EKn4HM;IAOI,6BAAA;EL+3HV;EKt4HM;IAOI,+BAAA;ELk4HV;EKz4HM;IAOI,6BAAA;ELq4HV;AACF;AMz6HA;ED4BQ;IAOI,0BAAA;EL04HV;EKj5HM;IAOI,gCAAA;EL64HV;EKp5HM;IAOI,yBAAA;ELg5HV;EKv5HM;IAOI,wBAAA;ELm5HV;EK15HM;IAOI,+BAAA;ELs5HV;EK75HM;IAOI,yBAAA;ELy5HV;EKh6HM;IAOI,6BAAA;EL45HV;EKn6HM;IAOI,8BAAA;EL+5HV;EKt6HM;IAOI,wBAAA;ELk6HV;EKz6HM;IAOI,+BAAA;ELq6HV;EK56HM;IAOI,wBAAA;ELw6HV;AACF","file":"bootstrap-grid.css","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n","// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n@if $enable-container-classes {\n // Single container class with breakpoint max-widths\n .container,\n // 100% wide container at all breakpoints\n .container-fluid {\n @include make-container();\n }\n\n // Responsive containers that are 100% wide until a breakpoint\n @each $breakpoint, $container-max-width in $container-max-widths {\n .container-#{$breakpoint} {\n @extend .container-fluid;\n }\n\n @include media-breakpoint-up($breakpoint, $grid-breakpoints) {\n %responsive-container-#{$breakpoint} {\n max-width: $container-max-width;\n }\n\n // Extend each breakpoint which is smaller or equal to the current breakpoint\n $extend-breakpoint: true;\n\n @each $name, $width in $grid-breakpoints {\n @if ($extend-breakpoint) {\n .container#{breakpoint-infix($name, $grid-breakpoints)} {\n @extend %responsive-container-#{$breakpoint};\n }\n\n // Once the current breakpoint is reached, stop extending\n @if ($breakpoint == $name) {\n $extend-breakpoint: false;\n }\n }\n }\n }\n }\n}\n","// Container mixins\n\n@mixin make-container($gutter: $container-padding-x) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-right: auto;\n margin-left: auto;\n}\n","/*!\n * Bootstrap Grid v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n.container,\n.container-fluid,\n.container-xxl,\n.container-xl,\n.container-lg,\n.container-md,\n.container-sm {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-right: auto;\n margin-left: auto;\n}\n\n@media (min-width: 576px) {\n .container-sm, .container {\n max-width: 540px;\n }\n}\n@media (min-width: 768px) {\n .container-md, .container-sm, .container {\n max-width: 720px;\n }\n}\n@media (min-width: 992px) {\n .container-lg, .container-md, .container-sm, .container {\n max-width: 960px;\n }\n}\n@media (min-width: 1200px) {\n .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1140px;\n }\n}\n@media (min-width: 1400px) {\n .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1320px;\n }\n}\n:root {\n --bs-breakpoint-xs: 0;\n --bs-breakpoint-sm: 576px;\n --bs-breakpoint-md: 768px;\n --bs-breakpoint-lg: 992px;\n --bs-breakpoint-xl: 1200px;\n --bs-breakpoint-xxl: 1400px;\n}\n\n.row {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n margin-top: calc(-1 * var(--bs-gutter-y));\n margin-right: calc(-0.5 * var(--bs-gutter-x));\n margin-left: calc(-0.5 * var(--bs-gutter-x));\n}\n.row > * {\n box-sizing: border-box;\n flex-shrink: 0;\n width: 100%;\n max-width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-top: var(--bs-gutter-y);\n}\n\n.col {\n flex: 1 0 0%;\n}\n\n.row-cols-auto > * {\n flex: 0 0 auto;\n width: auto;\n}\n\n.row-cols-1 > * {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.row-cols-2 > * {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.row-cols-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.row-cols-4 > * {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.row-cols-5 > * {\n flex: 0 0 auto;\n width: 20%;\n}\n\n.row-cols-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-auto {\n flex: 0 0 auto;\n width: auto;\n}\n\n.col-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n}\n\n.col-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-3 {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.col-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.col-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n}\n\n.col-6 {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.col-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n}\n\n.col-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n}\n\n.col-9 {\n flex: 0 0 auto;\n width: 75%;\n}\n\n.col-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n}\n\n.col-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n}\n\n.col-12 {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.offset-1 {\n margin-left: 8.33333333%;\n}\n\n.offset-2 {\n margin-left: 16.66666667%;\n}\n\n.offset-3 {\n margin-left: 25%;\n}\n\n.offset-4 {\n margin-left: 33.33333333%;\n}\n\n.offset-5 {\n margin-left: 41.66666667%;\n}\n\n.offset-6 {\n margin-left: 50%;\n}\n\n.offset-7 {\n margin-left: 58.33333333%;\n}\n\n.offset-8 {\n margin-left: 66.66666667%;\n}\n\n.offset-9 {\n margin-left: 75%;\n}\n\n.offset-10 {\n margin-left: 83.33333333%;\n}\n\n.offset-11 {\n margin-left: 91.66666667%;\n}\n\n.g-0,\n.gx-0 {\n --bs-gutter-x: 0;\n}\n\n.g-0,\n.gy-0 {\n --bs-gutter-y: 0;\n}\n\n.g-1,\n.gx-1 {\n --bs-gutter-x: 0.25rem;\n}\n\n.g-1,\n.gy-1 {\n --bs-gutter-y: 0.25rem;\n}\n\n.g-2,\n.gx-2 {\n --bs-gutter-x: 0.5rem;\n}\n\n.g-2,\n.gy-2 {\n --bs-gutter-y: 0.5rem;\n}\n\n.g-3,\n.gx-3 {\n --bs-gutter-x: 1rem;\n}\n\n.g-3,\n.gy-3 {\n --bs-gutter-y: 1rem;\n}\n\n.g-4,\n.gx-4 {\n --bs-gutter-x: 1.5rem;\n}\n\n.g-4,\n.gy-4 {\n --bs-gutter-y: 1.5rem;\n}\n\n.g-5,\n.gx-5 {\n --bs-gutter-x: 3rem;\n}\n\n.g-5,\n.gy-5 {\n --bs-gutter-y: 3rem;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n flex: 1 0 0%;\n }\n .row-cols-sm-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-sm-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-sm-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-sm-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-sm-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-sm-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-sm-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-sm-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-sm-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-sm-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-sm-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-sm-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-sm-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-sm-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-sm-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-sm-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-sm-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-sm-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-sm-0 {\n margin-left: 0;\n }\n .offset-sm-1 {\n margin-left: 8.33333333%;\n }\n .offset-sm-2 {\n margin-left: 16.66666667%;\n }\n .offset-sm-3 {\n margin-left: 25%;\n }\n .offset-sm-4 {\n margin-left: 33.33333333%;\n }\n .offset-sm-5 {\n margin-left: 41.66666667%;\n }\n .offset-sm-6 {\n margin-left: 50%;\n }\n .offset-sm-7 {\n margin-left: 58.33333333%;\n }\n .offset-sm-8 {\n margin-left: 66.66666667%;\n }\n .offset-sm-9 {\n margin-left: 75%;\n }\n .offset-sm-10 {\n margin-left: 83.33333333%;\n }\n .offset-sm-11 {\n margin-left: 91.66666667%;\n }\n .g-sm-0,\n .gx-sm-0 {\n --bs-gutter-x: 0;\n }\n .g-sm-0,\n .gy-sm-0 {\n --bs-gutter-y: 0;\n }\n .g-sm-1,\n .gx-sm-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-sm-1,\n .gy-sm-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-sm-2,\n .gx-sm-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-sm-2,\n .gy-sm-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-sm-3,\n .gx-sm-3 {\n --bs-gutter-x: 1rem;\n }\n .g-sm-3,\n .gy-sm-3 {\n --bs-gutter-y: 1rem;\n }\n .g-sm-4,\n .gx-sm-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-sm-4,\n .gy-sm-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-sm-5,\n .gx-sm-5 {\n --bs-gutter-x: 3rem;\n }\n .g-sm-5,\n .gy-sm-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 768px) {\n .col-md {\n flex: 1 0 0%;\n }\n .row-cols-md-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-md-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-md-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-md-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-md-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-md-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-md-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-md-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-md-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-md-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-md-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-md-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-md-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-md-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-md-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-md-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-md-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-md-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-md-0 {\n margin-left: 0;\n }\n .offset-md-1 {\n margin-left: 8.33333333%;\n }\n .offset-md-2 {\n margin-left: 16.66666667%;\n }\n .offset-md-3 {\n margin-left: 25%;\n }\n .offset-md-4 {\n margin-left: 33.33333333%;\n }\n .offset-md-5 {\n margin-left: 41.66666667%;\n }\n .offset-md-6 {\n margin-left: 50%;\n }\n .offset-md-7 {\n margin-left: 58.33333333%;\n }\n .offset-md-8 {\n margin-left: 66.66666667%;\n }\n .offset-md-9 {\n margin-left: 75%;\n }\n .offset-md-10 {\n margin-left: 83.33333333%;\n }\n .offset-md-11 {\n margin-left: 91.66666667%;\n }\n .g-md-0,\n .gx-md-0 {\n --bs-gutter-x: 0;\n }\n .g-md-0,\n .gy-md-0 {\n --bs-gutter-y: 0;\n }\n .g-md-1,\n .gx-md-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-md-1,\n .gy-md-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-md-2,\n .gx-md-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-md-2,\n .gy-md-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-md-3,\n .gx-md-3 {\n --bs-gutter-x: 1rem;\n }\n .g-md-3,\n .gy-md-3 {\n --bs-gutter-y: 1rem;\n }\n .g-md-4,\n .gx-md-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-md-4,\n .gy-md-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-md-5,\n .gx-md-5 {\n --bs-gutter-x: 3rem;\n }\n .g-md-5,\n .gy-md-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 992px) {\n .col-lg {\n flex: 1 0 0%;\n }\n .row-cols-lg-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-lg-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-lg-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-lg-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-lg-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-lg-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-lg-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-lg-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-lg-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-lg-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-lg-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-lg-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-lg-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-lg-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-lg-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-lg-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-lg-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-lg-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-lg-0 {\n margin-left: 0;\n }\n .offset-lg-1 {\n margin-left: 8.33333333%;\n }\n .offset-lg-2 {\n margin-left: 16.66666667%;\n }\n .offset-lg-3 {\n margin-left: 25%;\n }\n .offset-lg-4 {\n margin-left: 33.33333333%;\n }\n .offset-lg-5 {\n margin-left: 41.66666667%;\n }\n .offset-lg-6 {\n margin-left: 50%;\n }\n .offset-lg-7 {\n margin-left: 58.33333333%;\n }\n .offset-lg-8 {\n margin-left: 66.66666667%;\n }\n .offset-lg-9 {\n margin-left: 75%;\n }\n .offset-lg-10 {\n margin-left: 83.33333333%;\n }\n .offset-lg-11 {\n margin-left: 91.66666667%;\n }\n .g-lg-0,\n .gx-lg-0 {\n --bs-gutter-x: 0;\n }\n .g-lg-0,\n .gy-lg-0 {\n --bs-gutter-y: 0;\n }\n .g-lg-1,\n .gx-lg-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-lg-1,\n .gy-lg-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-lg-2,\n .gx-lg-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-lg-2,\n .gy-lg-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-lg-3,\n .gx-lg-3 {\n --bs-gutter-x: 1rem;\n }\n .g-lg-3,\n .gy-lg-3 {\n --bs-gutter-y: 1rem;\n }\n .g-lg-4,\n .gx-lg-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-lg-4,\n .gy-lg-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-lg-5,\n .gx-lg-5 {\n --bs-gutter-x: 3rem;\n }\n .g-lg-5,\n .gy-lg-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1200px) {\n .col-xl {\n flex: 1 0 0%;\n }\n .row-cols-xl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xl-0 {\n margin-left: 0;\n }\n .offset-xl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xl-3 {\n margin-left: 25%;\n }\n .offset-xl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xl-6 {\n margin-left: 50%;\n }\n .offset-xl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xl-9 {\n margin-left: 75%;\n }\n .offset-xl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xl-11 {\n margin-left: 91.66666667%;\n }\n .g-xl-0,\n .gx-xl-0 {\n --bs-gutter-x: 0;\n }\n .g-xl-0,\n .gy-xl-0 {\n --bs-gutter-y: 0;\n }\n .g-xl-1,\n .gx-xl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xl-1,\n .gy-xl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xl-2,\n .gx-xl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xl-2,\n .gy-xl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xl-3,\n .gx-xl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xl-3,\n .gy-xl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xl-4,\n .gx-xl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xl-4,\n .gy-xl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xl-5,\n .gx-xl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xl-5,\n .gy-xl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1400px) {\n .col-xxl {\n flex: 1 0 0%;\n }\n .row-cols-xxl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xxl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xxl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xxl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xxl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xxl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xxl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xxl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xxl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xxl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xxl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xxl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xxl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xxl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xxl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xxl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xxl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xxl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xxl-0 {\n margin-left: 0;\n }\n .offset-xxl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xxl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xxl-3 {\n margin-left: 25%;\n }\n .offset-xxl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xxl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xxl-6 {\n margin-left: 50%;\n }\n .offset-xxl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xxl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xxl-9 {\n margin-left: 75%;\n }\n .offset-xxl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xxl-11 {\n margin-left: 91.66666667%;\n }\n .g-xxl-0,\n .gx-xxl-0 {\n --bs-gutter-x: 0;\n }\n .g-xxl-0,\n .gy-xxl-0 {\n --bs-gutter-y: 0;\n }\n .g-xxl-1,\n .gx-xxl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xxl-1,\n .gy-xxl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xxl-2,\n .gx-xxl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xxl-2,\n .gy-xxl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xxl-3,\n .gx-xxl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xxl-3,\n .gy-xxl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xxl-4,\n .gx-xxl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xxl-4,\n .gy-xxl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xxl-5,\n .gx-xxl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xxl-5,\n .gy-xxl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-grid {\n display: grid !important;\n}\n\n.d-inline-grid {\n display: inline-grid !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: flex !important;\n}\n\n.d-inline-flex {\n display: inline-flex !important;\n}\n\n.d-none {\n display: none !important;\n}\n\n.flex-fill {\n flex: 1 1 auto !important;\n}\n\n.flex-row {\n flex-direction: row !important;\n}\n\n.flex-column {\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n flex-direction: column-reverse !important;\n}\n\n.flex-grow-0 {\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n flex-shrink: 1 !important;\n}\n\n.flex-wrap {\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n}\n\n.justify-content-start {\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n justify-content: center !important;\n}\n\n.justify-content-between {\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n justify-content: space-around !important;\n}\n\n.justify-content-evenly {\n justify-content: space-evenly !important;\n}\n\n.align-items-start {\n align-items: flex-start !important;\n}\n\n.align-items-end {\n align-items: flex-end !important;\n}\n\n.align-items-center {\n align-items: center !important;\n}\n\n.align-items-baseline {\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n align-items: stretch !important;\n}\n\n.align-content-start {\n align-content: flex-start !important;\n}\n\n.align-content-end {\n align-content: flex-end !important;\n}\n\n.align-content-center {\n align-content: center !important;\n}\n\n.align-content-between {\n align-content: space-between !important;\n}\n\n.align-content-around {\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n align-content: stretch !important;\n}\n\n.align-self-auto {\n align-self: auto !important;\n}\n\n.align-self-start {\n align-self: flex-start !important;\n}\n\n.align-self-end {\n align-self: flex-end !important;\n}\n\n.align-self-center {\n align-self: center !important;\n}\n\n.align-self-baseline {\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n align-self: stretch !important;\n}\n\n.order-first {\n order: -1 !important;\n}\n\n.order-0 {\n order: 0 !important;\n}\n\n.order-1 {\n order: 1 !important;\n}\n\n.order-2 {\n order: 2 !important;\n}\n\n.order-3 {\n order: 3 !important;\n}\n\n.order-4 {\n order: 4 !important;\n}\n\n.order-5 {\n order: 5 !important;\n}\n\n.order-last {\n order: 6 !important;\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mx-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n}\n\n.mx-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n}\n\n.mx-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n}\n\n.mx-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n}\n\n.mx-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n}\n\n.mx-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n}\n\n.mx-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n}\n\n.my-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n}\n\n.my-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n}\n\n.my-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n}\n\n.my-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n}\n\n.my-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n}\n\n.my-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n}\n\n.my-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n}\n\n.mt-0 {\n margin-top: 0 !important;\n}\n\n.mt-1 {\n margin-top: 0.25rem !important;\n}\n\n.mt-2 {\n margin-top: 0.5rem !important;\n}\n\n.mt-3 {\n margin-top: 1rem !important;\n}\n\n.mt-4 {\n margin-top: 1.5rem !important;\n}\n\n.mt-5 {\n margin-top: 3rem !important;\n}\n\n.mt-auto {\n margin-top: auto !important;\n}\n\n.me-0 {\n margin-right: 0 !important;\n}\n\n.me-1 {\n margin-right: 0.25rem !important;\n}\n\n.me-2 {\n margin-right: 0.5rem !important;\n}\n\n.me-3 {\n margin-right: 1rem !important;\n}\n\n.me-4 {\n margin-right: 1.5rem !important;\n}\n\n.me-5 {\n margin-right: 3rem !important;\n}\n\n.me-auto {\n margin-right: auto !important;\n}\n\n.mb-0 {\n margin-bottom: 0 !important;\n}\n\n.mb-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.mb-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.mb-3 {\n margin-bottom: 1rem !important;\n}\n\n.mb-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.mb-5 {\n margin-bottom: 3rem !important;\n}\n\n.mb-auto {\n margin-bottom: auto !important;\n}\n\n.ms-0 {\n margin-left: 0 !important;\n}\n\n.ms-1 {\n margin-left: 0.25rem !important;\n}\n\n.ms-2 {\n margin-left: 0.5rem !important;\n}\n\n.ms-3 {\n margin-left: 1rem !important;\n}\n\n.ms-4 {\n margin-left: 1.5rem !important;\n}\n\n.ms-5 {\n margin-left: 3rem !important;\n}\n\n.ms-auto {\n margin-left: auto !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.px-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n}\n\n.px-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n}\n\n.px-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n}\n\n.px-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n}\n\n.px-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n}\n\n.px-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n}\n\n.py-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n}\n\n.py-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n}\n\n.py-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n}\n\n.py-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n}\n\n.py-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n}\n\n.py-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n}\n\n.pt-0 {\n padding-top: 0 !important;\n}\n\n.pt-1 {\n padding-top: 0.25rem !important;\n}\n\n.pt-2 {\n padding-top: 0.5rem !important;\n}\n\n.pt-3 {\n padding-top: 1rem !important;\n}\n\n.pt-4 {\n padding-top: 1.5rem !important;\n}\n\n.pt-5 {\n padding-top: 3rem !important;\n}\n\n.pe-0 {\n padding-right: 0 !important;\n}\n\n.pe-1 {\n padding-right: 0.25rem !important;\n}\n\n.pe-2 {\n padding-right: 0.5rem !important;\n}\n\n.pe-3 {\n padding-right: 1rem !important;\n}\n\n.pe-4 {\n padding-right: 1.5rem !important;\n}\n\n.pe-5 {\n padding-right: 3rem !important;\n}\n\n.pb-0 {\n padding-bottom: 0 !important;\n}\n\n.pb-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pb-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pb-3 {\n padding-bottom: 1rem !important;\n}\n\n.pb-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pb-5 {\n padding-bottom: 3rem !important;\n}\n\n.ps-0 {\n padding-left: 0 !important;\n}\n\n.ps-1 {\n padding-left: 0.25rem !important;\n}\n\n.ps-2 {\n padding-left: 0.5rem !important;\n}\n\n.ps-3 {\n padding-left: 1rem !important;\n}\n\n.ps-4 {\n padding-left: 1.5rem !important;\n}\n\n.ps-5 {\n padding-left: 3rem !important;\n}\n\n@media (min-width: 576px) {\n .d-sm-inline {\n display: inline !important;\n }\n .d-sm-inline-block {\n display: inline-block !important;\n }\n .d-sm-block {\n display: block !important;\n }\n .d-sm-grid {\n display: grid !important;\n }\n .d-sm-inline-grid {\n display: inline-grid !important;\n }\n .d-sm-table {\n display: table !important;\n }\n .d-sm-table-row {\n display: table-row !important;\n }\n .d-sm-table-cell {\n display: table-cell !important;\n }\n .d-sm-flex {\n display: flex !important;\n }\n .d-sm-inline-flex {\n display: inline-flex !important;\n }\n .d-sm-none {\n display: none !important;\n }\n .flex-sm-fill {\n flex: 1 1 auto !important;\n }\n .flex-sm-row {\n flex-direction: row !important;\n }\n .flex-sm-column {\n flex-direction: column !important;\n }\n .flex-sm-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-sm-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-sm-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-sm-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-sm-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-sm-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-sm-wrap {\n flex-wrap: wrap !important;\n }\n .flex-sm-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-sm-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-sm-start {\n justify-content: flex-start !important;\n }\n .justify-content-sm-end {\n justify-content: flex-end !important;\n }\n .justify-content-sm-center {\n justify-content: center !important;\n }\n .justify-content-sm-between {\n justify-content: space-between !important;\n }\n .justify-content-sm-around {\n justify-content: space-around !important;\n }\n .justify-content-sm-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-sm-start {\n align-items: flex-start !important;\n }\n .align-items-sm-end {\n align-items: flex-end !important;\n }\n .align-items-sm-center {\n align-items: center !important;\n }\n .align-items-sm-baseline {\n align-items: baseline !important;\n }\n .align-items-sm-stretch {\n align-items: stretch !important;\n }\n .align-content-sm-start {\n align-content: flex-start !important;\n }\n .align-content-sm-end {\n align-content: flex-end !important;\n }\n .align-content-sm-center {\n align-content: center !important;\n }\n .align-content-sm-between {\n align-content: space-between !important;\n }\n .align-content-sm-around {\n align-content: space-around !important;\n }\n .align-content-sm-stretch {\n align-content: stretch !important;\n }\n .align-self-sm-auto {\n align-self: auto !important;\n }\n .align-self-sm-start {\n align-self: flex-start !important;\n }\n .align-self-sm-end {\n align-self: flex-end !important;\n }\n .align-self-sm-center {\n align-self: center !important;\n }\n .align-self-sm-baseline {\n align-self: baseline !important;\n }\n .align-self-sm-stretch {\n align-self: stretch !important;\n }\n .order-sm-first {\n order: -1 !important;\n }\n .order-sm-0 {\n order: 0 !important;\n }\n .order-sm-1 {\n order: 1 !important;\n }\n .order-sm-2 {\n order: 2 !important;\n }\n .order-sm-3 {\n order: 3 !important;\n }\n .order-sm-4 {\n order: 4 !important;\n }\n .order-sm-5 {\n order: 5 !important;\n }\n .order-sm-last {\n order: 6 !important;\n }\n .m-sm-0 {\n margin: 0 !important;\n }\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n .m-sm-3 {\n margin: 1rem !important;\n }\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n .m-sm-5 {\n margin: 3rem !important;\n }\n .m-sm-auto {\n margin: auto !important;\n }\n .mx-sm-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-sm-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-sm-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-sm-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-sm-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-sm-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-sm-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-sm-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-sm-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-sm-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-sm-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-sm-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-sm-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-sm-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-sm-0 {\n margin-top: 0 !important;\n }\n .mt-sm-1 {\n margin-top: 0.25rem !important;\n }\n .mt-sm-2 {\n margin-top: 0.5rem !important;\n }\n .mt-sm-3 {\n margin-top: 1rem !important;\n }\n .mt-sm-4 {\n margin-top: 1.5rem !important;\n }\n .mt-sm-5 {\n margin-top: 3rem !important;\n }\n .mt-sm-auto {\n margin-top: auto !important;\n }\n .me-sm-0 {\n margin-right: 0 !important;\n }\n .me-sm-1 {\n margin-right: 0.25rem !important;\n }\n .me-sm-2 {\n margin-right: 0.5rem !important;\n }\n .me-sm-3 {\n margin-right: 1rem !important;\n }\n .me-sm-4 {\n margin-right: 1.5rem !important;\n }\n .me-sm-5 {\n margin-right: 3rem !important;\n }\n .me-sm-auto {\n margin-right: auto !important;\n }\n .mb-sm-0 {\n margin-bottom: 0 !important;\n }\n .mb-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-sm-3 {\n margin-bottom: 1rem !important;\n }\n .mb-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-sm-5 {\n margin-bottom: 3rem !important;\n }\n .mb-sm-auto {\n margin-bottom: auto !important;\n }\n .ms-sm-0 {\n margin-left: 0 !important;\n }\n .ms-sm-1 {\n margin-left: 0.25rem !important;\n }\n .ms-sm-2 {\n margin-left: 0.5rem !important;\n }\n .ms-sm-3 {\n margin-left: 1rem !important;\n }\n .ms-sm-4 {\n margin-left: 1.5rem !important;\n }\n .ms-sm-5 {\n margin-left: 3rem !important;\n }\n .ms-sm-auto {\n margin-left: auto !important;\n }\n .p-sm-0 {\n padding: 0 !important;\n }\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n .p-sm-3 {\n padding: 1rem !important;\n }\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n .p-sm-5 {\n padding: 3rem !important;\n }\n .px-sm-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-sm-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-sm-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-sm-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-sm-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-sm-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-sm-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-sm-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-sm-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-sm-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-sm-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-sm-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-sm-0 {\n padding-top: 0 !important;\n }\n .pt-sm-1 {\n padding-top: 0.25rem !important;\n }\n .pt-sm-2 {\n padding-top: 0.5rem !important;\n }\n .pt-sm-3 {\n padding-top: 1rem !important;\n }\n .pt-sm-4 {\n padding-top: 1.5rem !important;\n }\n .pt-sm-5 {\n padding-top: 3rem !important;\n }\n .pe-sm-0 {\n padding-right: 0 !important;\n }\n .pe-sm-1 {\n padding-right: 0.25rem !important;\n }\n .pe-sm-2 {\n padding-right: 0.5rem !important;\n }\n .pe-sm-3 {\n padding-right: 1rem !important;\n }\n .pe-sm-4 {\n padding-right: 1.5rem !important;\n }\n .pe-sm-5 {\n padding-right: 3rem !important;\n }\n .pb-sm-0 {\n padding-bottom: 0 !important;\n }\n .pb-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-sm-3 {\n padding-bottom: 1rem !important;\n }\n .pb-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-sm-5 {\n padding-bottom: 3rem !important;\n }\n .ps-sm-0 {\n padding-left: 0 !important;\n }\n .ps-sm-1 {\n padding-left: 0.25rem !important;\n }\n .ps-sm-2 {\n padding-left: 0.5rem !important;\n }\n .ps-sm-3 {\n padding-left: 1rem !important;\n }\n .ps-sm-4 {\n padding-left: 1.5rem !important;\n }\n .ps-sm-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 768px) {\n .d-md-inline {\n display: inline !important;\n }\n .d-md-inline-block {\n display: inline-block !important;\n }\n .d-md-block {\n display: block !important;\n }\n .d-md-grid {\n display: grid !important;\n }\n .d-md-inline-grid {\n display: inline-grid !important;\n }\n .d-md-table {\n display: table !important;\n }\n .d-md-table-row {\n display: table-row !important;\n }\n .d-md-table-cell {\n display: table-cell !important;\n }\n .d-md-flex {\n display: flex !important;\n }\n .d-md-inline-flex {\n display: inline-flex !important;\n }\n .d-md-none {\n display: none !important;\n }\n .flex-md-fill {\n flex: 1 1 auto !important;\n }\n .flex-md-row {\n flex-direction: row !important;\n }\n .flex-md-column {\n flex-direction: column !important;\n }\n .flex-md-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-md-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-md-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-md-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-md-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-md-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-md-wrap {\n flex-wrap: wrap !important;\n }\n .flex-md-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-md-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-md-start {\n justify-content: flex-start !important;\n }\n .justify-content-md-end {\n justify-content: flex-end !important;\n }\n .justify-content-md-center {\n justify-content: center !important;\n }\n .justify-content-md-between {\n justify-content: space-between !important;\n }\n .justify-content-md-around {\n justify-content: space-around !important;\n }\n .justify-content-md-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-md-start {\n align-items: flex-start !important;\n }\n .align-items-md-end {\n align-items: flex-end !important;\n }\n .align-items-md-center {\n align-items: center !important;\n }\n .align-items-md-baseline {\n align-items: baseline !important;\n }\n .align-items-md-stretch {\n align-items: stretch !important;\n }\n .align-content-md-start {\n align-content: flex-start !important;\n }\n .align-content-md-end {\n align-content: flex-end !important;\n }\n .align-content-md-center {\n align-content: center !important;\n }\n .align-content-md-between {\n align-content: space-between !important;\n }\n .align-content-md-around {\n align-content: space-around !important;\n }\n .align-content-md-stretch {\n align-content: stretch !important;\n }\n .align-self-md-auto {\n align-self: auto !important;\n }\n .align-self-md-start {\n align-self: flex-start !important;\n }\n .align-self-md-end {\n align-self: flex-end !important;\n }\n .align-self-md-center {\n align-self: center !important;\n }\n .align-self-md-baseline {\n align-self: baseline !important;\n }\n .align-self-md-stretch {\n align-self: stretch !important;\n }\n .order-md-first {\n order: -1 !important;\n }\n .order-md-0 {\n order: 0 !important;\n }\n .order-md-1 {\n order: 1 !important;\n }\n .order-md-2 {\n order: 2 !important;\n }\n .order-md-3 {\n order: 3 !important;\n }\n .order-md-4 {\n order: 4 !important;\n }\n .order-md-5 {\n order: 5 !important;\n }\n .order-md-last {\n order: 6 !important;\n }\n .m-md-0 {\n margin: 0 !important;\n }\n .m-md-1 {\n margin: 0.25rem !important;\n }\n .m-md-2 {\n margin: 0.5rem !important;\n }\n .m-md-3 {\n margin: 1rem !important;\n }\n .m-md-4 {\n margin: 1.5rem !important;\n }\n .m-md-5 {\n margin: 3rem !important;\n }\n .m-md-auto {\n margin: auto !important;\n }\n .mx-md-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-md-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-md-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-md-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-md-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-md-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-md-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-md-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-md-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-md-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-md-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-md-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-md-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-md-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-md-0 {\n margin-top: 0 !important;\n }\n .mt-md-1 {\n margin-top: 0.25rem !important;\n }\n .mt-md-2 {\n margin-top: 0.5rem !important;\n }\n .mt-md-3 {\n margin-top: 1rem !important;\n }\n .mt-md-4 {\n margin-top: 1.5rem !important;\n }\n .mt-md-5 {\n margin-top: 3rem !important;\n }\n .mt-md-auto {\n margin-top: auto !important;\n }\n .me-md-0 {\n margin-right: 0 !important;\n }\n .me-md-1 {\n margin-right: 0.25rem !important;\n }\n .me-md-2 {\n margin-right: 0.5rem !important;\n }\n .me-md-3 {\n margin-right: 1rem !important;\n }\n .me-md-4 {\n margin-right: 1.5rem !important;\n }\n .me-md-5 {\n margin-right: 3rem !important;\n }\n .me-md-auto {\n margin-right: auto !important;\n }\n .mb-md-0 {\n margin-bottom: 0 !important;\n }\n .mb-md-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-md-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-md-3 {\n margin-bottom: 1rem !important;\n }\n .mb-md-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-md-5 {\n margin-bottom: 3rem !important;\n }\n .mb-md-auto {\n margin-bottom: auto !important;\n }\n .ms-md-0 {\n margin-left: 0 !important;\n }\n .ms-md-1 {\n margin-left: 0.25rem !important;\n }\n .ms-md-2 {\n margin-left: 0.5rem !important;\n }\n .ms-md-3 {\n margin-left: 1rem !important;\n }\n .ms-md-4 {\n margin-left: 1.5rem !important;\n }\n .ms-md-5 {\n margin-left: 3rem !important;\n }\n .ms-md-auto {\n margin-left: auto !important;\n }\n .p-md-0 {\n padding: 0 !important;\n }\n .p-md-1 {\n padding: 0.25rem !important;\n }\n .p-md-2 {\n padding: 0.5rem !important;\n }\n .p-md-3 {\n padding: 1rem !important;\n }\n .p-md-4 {\n padding: 1.5rem !important;\n }\n .p-md-5 {\n padding: 3rem !important;\n }\n .px-md-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-md-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-md-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-md-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-md-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-md-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-md-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-md-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-md-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-md-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-md-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-md-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-md-0 {\n padding-top: 0 !important;\n }\n .pt-md-1 {\n padding-top: 0.25rem !important;\n }\n .pt-md-2 {\n padding-top: 0.5rem !important;\n }\n .pt-md-3 {\n padding-top: 1rem !important;\n }\n .pt-md-4 {\n padding-top: 1.5rem !important;\n }\n .pt-md-5 {\n padding-top: 3rem !important;\n }\n .pe-md-0 {\n padding-right: 0 !important;\n }\n .pe-md-1 {\n padding-right: 0.25rem !important;\n }\n .pe-md-2 {\n padding-right: 0.5rem !important;\n }\n .pe-md-3 {\n padding-right: 1rem !important;\n }\n .pe-md-4 {\n padding-right: 1.5rem !important;\n }\n .pe-md-5 {\n padding-right: 3rem !important;\n }\n .pb-md-0 {\n padding-bottom: 0 !important;\n }\n .pb-md-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-md-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-md-3 {\n padding-bottom: 1rem !important;\n }\n .pb-md-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-md-5 {\n padding-bottom: 3rem !important;\n }\n .ps-md-0 {\n padding-left: 0 !important;\n }\n .ps-md-1 {\n padding-left: 0.25rem !important;\n }\n .ps-md-2 {\n padding-left: 0.5rem !important;\n }\n .ps-md-3 {\n padding-left: 1rem !important;\n }\n .ps-md-4 {\n padding-left: 1.5rem !important;\n }\n .ps-md-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 992px) {\n .d-lg-inline {\n display: inline !important;\n }\n .d-lg-inline-block {\n display: inline-block !important;\n }\n .d-lg-block {\n display: block !important;\n }\n .d-lg-grid {\n display: grid !important;\n }\n .d-lg-inline-grid {\n display: inline-grid !important;\n }\n .d-lg-table {\n display: table !important;\n }\n .d-lg-table-row {\n display: table-row !important;\n }\n .d-lg-table-cell {\n display: table-cell !important;\n }\n .d-lg-flex {\n display: flex !important;\n }\n .d-lg-inline-flex {\n display: inline-flex !important;\n }\n .d-lg-none {\n display: none !important;\n }\n .flex-lg-fill {\n flex: 1 1 auto !important;\n }\n .flex-lg-row {\n flex-direction: row !important;\n }\n .flex-lg-column {\n flex-direction: column !important;\n }\n .flex-lg-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-lg-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-lg-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-lg-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-lg-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-lg-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-lg-wrap {\n flex-wrap: wrap !important;\n }\n .flex-lg-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-lg-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-lg-start {\n justify-content: flex-start !important;\n }\n .justify-content-lg-end {\n justify-content: flex-end !important;\n }\n .justify-content-lg-center {\n justify-content: center !important;\n }\n .justify-content-lg-between {\n justify-content: space-between !important;\n }\n .justify-content-lg-around {\n justify-content: space-around !important;\n }\n .justify-content-lg-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-lg-start {\n align-items: flex-start !important;\n }\n .align-items-lg-end {\n align-items: flex-end !important;\n }\n .align-items-lg-center {\n align-items: center !important;\n }\n .align-items-lg-baseline {\n align-items: baseline !important;\n }\n .align-items-lg-stretch {\n align-items: stretch !important;\n }\n .align-content-lg-start {\n align-content: flex-start !important;\n }\n .align-content-lg-end {\n align-content: flex-end !important;\n }\n .align-content-lg-center {\n align-content: center !important;\n }\n .align-content-lg-between {\n align-content: space-between !important;\n }\n .align-content-lg-around {\n align-content: space-around !important;\n }\n .align-content-lg-stretch {\n align-content: stretch !important;\n }\n .align-self-lg-auto {\n align-self: auto !important;\n }\n .align-self-lg-start {\n align-self: flex-start !important;\n }\n .align-self-lg-end {\n align-self: flex-end !important;\n }\n .align-self-lg-center {\n align-self: center !important;\n }\n .align-self-lg-baseline {\n align-self: baseline !important;\n }\n .align-self-lg-stretch {\n align-self: stretch !important;\n }\n .order-lg-first {\n order: -1 !important;\n }\n .order-lg-0 {\n order: 0 !important;\n }\n .order-lg-1 {\n order: 1 !important;\n }\n .order-lg-2 {\n order: 2 !important;\n }\n .order-lg-3 {\n order: 3 !important;\n }\n .order-lg-4 {\n order: 4 !important;\n }\n .order-lg-5 {\n order: 5 !important;\n }\n .order-lg-last {\n order: 6 !important;\n }\n .m-lg-0 {\n margin: 0 !important;\n }\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n .m-lg-3 {\n margin: 1rem !important;\n }\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n .m-lg-5 {\n margin: 3rem !important;\n }\n .m-lg-auto {\n margin: auto !important;\n }\n .mx-lg-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-lg-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-lg-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-lg-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-lg-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-lg-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-lg-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-lg-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-lg-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-lg-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-lg-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-lg-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-lg-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-lg-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-lg-0 {\n margin-top: 0 !important;\n }\n .mt-lg-1 {\n margin-top: 0.25rem !important;\n }\n .mt-lg-2 {\n margin-top: 0.5rem !important;\n }\n .mt-lg-3 {\n margin-top: 1rem !important;\n }\n .mt-lg-4 {\n margin-top: 1.5rem !important;\n }\n .mt-lg-5 {\n margin-top: 3rem !important;\n }\n .mt-lg-auto {\n margin-top: auto !important;\n }\n .me-lg-0 {\n margin-right: 0 !important;\n }\n .me-lg-1 {\n margin-right: 0.25rem !important;\n }\n .me-lg-2 {\n margin-right: 0.5rem !important;\n }\n .me-lg-3 {\n margin-right: 1rem !important;\n }\n .me-lg-4 {\n margin-right: 1.5rem !important;\n }\n .me-lg-5 {\n margin-right: 3rem !important;\n }\n .me-lg-auto {\n margin-right: auto !important;\n }\n .mb-lg-0 {\n margin-bottom: 0 !important;\n }\n .mb-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-lg-3 {\n margin-bottom: 1rem !important;\n }\n .mb-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-lg-5 {\n margin-bottom: 3rem !important;\n }\n .mb-lg-auto {\n margin-bottom: auto !important;\n }\n .ms-lg-0 {\n margin-left: 0 !important;\n }\n .ms-lg-1 {\n margin-left: 0.25rem !important;\n }\n .ms-lg-2 {\n margin-left: 0.5rem !important;\n }\n .ms-lg-3 {\n margin-left: 1rem !important;\n }\n .ms-lg-4 {\n margin-left: 1.5rem !important;\n }\n .ms-lg-5 {\n margin-left: 3rem !important;\n }\n .ms-lg-auto {\n margin-left: auto !important;\n }\n .p-lg-0 {\n padding: 0 !important;\n }\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n .p-lg-3 {\n padding: 1rem !important;\n }\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n .p-lg-5 {\n padding: 3rem !important;\n }\n .px-lg-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-lg-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-lg-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-lg-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-lg-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-lg-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-lg-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-lg-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-lg-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-lg-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-lg-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-lg-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-lg-0 {\n padding-top: 0 !important;\n }\n .pt-lg-1 {\n padding-top: 0.25rem !important;\n }\n .pt-lg-2 {\n padding-top: 0.5rem !important;\n }\n .pt-lg-3 {\n padding-top: 1rem !important;\n }\n .pt-lg-4 {\n padding-top: 1.5rem !important;\n }\n .pt-lg-5 {\n padding-top: 3rem !important;\n }\n .pe-lg-0 {\n padding-right: 0 !important;\n }\n .pe-lg-1 {\n padding-right: 0.25rem !important;\n }\n .pe-lg-2 {\n padding-right: 0.5rem !important;\n }\n .pe-lg-3 {\n padding-right: 1rem !important;\n }\n .pe-lg-4 {\n padding-right: 1.5rem !important;\n }\n .pe-lg-5 {\n padding-right: 3rem !important;\n }\n .pb-lg-0 {\n padding-bottom: 0 !important;\n }\n .pb-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-lg-3 {\n padding-bottom: 1rem !important;\n }\n .pb-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-lg-5 {\n padding-bottom: 3rem !important;\n }\n .ps-lg-0 {\n padding-left: 0 !important;\n }\n .ps-lg-1 {\n padding-left: 0.25rem !important;\n }\n .ps-lg-2 {\n padding-left: 0.5rem !important;\n }\n .ps-lg-3 {\n padding-left: 1rem !important;\n }\n .ps-lg-4 {\n padding-left: 1.5rem !important;\n }\n .ps-lg-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 1200px) {\n .d-xl-inline {\n display: inline !important;\n }\n .d-xl-inline-block {\n display: inline-block !important;\n }\n .d-xl-block {\n display: block !important;\n }\n .d-xl-grid {\n display: grid !important;\n }\n .d-xl-inline-grid {\n display: inline-grid !important;\n }\n .d-xl-table {\n display: table !important;\n }\n .d-xl-table-row {\n display: table-row !important;\n }\n .d-xl-table-cell {\n display: table-cell !important;\n }\n .d-xl-flex {\n display: flex !important;\n }\n .d-xl-inline-flex {\n display: inline-flex !important;\n }\n .d-xl-none {\n display: none !important;\n }\n .flex-xl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xl-row {\n flex-direction: row !important;\n }\n .flex-xl-column {\n flex-direction: column !important;\n }\n .flex-xl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xl-center {\n justify-content: center !important;\n }\n .justify-content-xl-between {\n justify-content: space-between !important;\n }\n .justify-content-xl-around {\n justify-content: space-around !important;\n }\n .justify-content-xl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xl-start {\n align-items: flex-start !important;\n }\n .align-items-xl-end {\n align-items: flex-end !important;\n }\n .align-items-xl-center {\n align-items: center !important;\n }\n .align-items-xl-baseline {\n align-items: baseline !important;\n }\n .align-items-xl-stretch {\n align-items: stretch !important;\n }\n .align-content-xl-start {\n align-content: flex-start !important;\n }\n .align-content-xl-end {\n align-content: flex-end !important;\n }\n .align-content-xl-center {\n align-content: center !important;\n }\n .align-content-xl-between {\n align-content: space-between !important;\n }\n .align-content-xl-around {\n align-content: space-around !important;\n }\n .align-content-xl-stretch {\n align-content: stretch !important;\n }\n .align-self-xl-auto {\n align-self: auto !important;\n }\n .align-self-xl-start {\n align-self: flex-start !important;\n }\n .align-self-xl-end {\n align-self: flex-end !important;\n }\n .align-self-xl-center {\n align-self: center !important;\n }\n .align-self-xl-baseline {\n align-self: baseline !important;\n }\n .align-self-xl-stretch {\n align-self: stretch !important;\n }\n .order-xl-first {\n order: -1 !important;\n }\n .order-xl-0 {\n order: 0 !important;\n }\n .order-xl-1 {\n order: 1 !important;\n }\n .order-xl-2 {\n order: 2 !important;\n }\n .order-xl-3 {\n order: 3 !important;\n }\n .order-xl-4 {\n order: 4 !important;\n }\n .order-xl-5 {\n order: 5 !important;\n }\n .order-xl-last {\n order: 6 !important;\n }\n .m-xl-0 {\n margin: 0 !important;\n }\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n .m-xl-3 {\n margin: 1rem !important;\n }\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n .m-xl-5 {\n margin: 3rem !important;\n }\n .m-xl-auto {\n margin: auto !important;\n }\n .mx-xl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xl-0 {\n margin-top: 0 !important;\n }\n .mt-xl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xl-3 {\n margin-top: 1rem !important;\n }\n .mt-xl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xl-5 {\n margin-top: 3rem !important;\n }\n .mt-xl-auto {\n margin-top: auto !important;\n }\n .me-xl-0 {\n margin-right: 0 !important;\n }\n .me-xl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xl-3 {\n margin-right: 1rem !important;\n }\n .me-xl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xl-5 {\n margin-right: 3rem !important;\n }\n .me-xl-auto {\n margin-right: auto !important;\n }\n .mb-xl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xl-auto {\n margin-bottom: auto !important;\n }\n .ms-xl-0 {\n margin-left: 0 !important;\n }\n .ms-xl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xl-3 {\n margin-left: 1rem !important;\n }\n .ms-xl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xl-5 {\n margin-left: 3rem !important;\n }\n .ms-xl-auto {\n margin-left: auto !important;\n }\n .p-xl-0 {\n padding: 0 !important;\n }\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n .p-xl-3 {\n padding: 1rem !important;\n }\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n .p-xl-5 {\n padding: 3rem !important;\n }\n .px-xl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xl-0 {\n padding-top: 0 !important;\n }\n .pt-xl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xl-3 {\n padding-top: 1rem !important;\n }\n .pt-xl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xl-5 {\n padding-top: 3rem !important;\n }\n .pe-xl-0 {\n padding-right: 0 !important;\n }\n .pe-xl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xl-3 {\n padding-right: 1rem !important;\n }\n .pe-xl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xl-5 {\n padding-right: 3rem !important;\n }\n .pb-xl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xl-0 {\n padding-left: 0 !important;\n }\n .ps-xl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xl-3 {\n padding-left: 1rem !important;\n }\n .ps-xl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xl-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 1400px) {\n .d-xxl-inline {\n display: inline !important;\n }\n .d-xxl-inline-block {\n display: inline-block !important;\n }\n .d-xxl-block {\n display: block !important;\n }\n .d-xxl-grid {\n display: grid !important;\n }\n .d-xxl-inline-grid {\n display: inline-grid !important;\n }\n .d-xxl-table {\n display: table !important;\n }\n .d-xxl-table-row {\n display: table-row !important;\n }\n .d-xxl-table-cell {\n display: table-cell !important;\n }\n .d-xxl-flex {\n display: flex !important;\n }\n .d-xxl-inline-flex {\n display: inline-flex !important;\n }\n .d-xxl-none {\n display: none !important;\n }\n .flex-xxl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xxl-row {\n flex-direction: row !important;\n }\n .flex-xxl-column {\n flex-direction: column !important;\n }\n .flex-xxl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xxl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xxl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xxl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xxl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xxl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xxl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xxl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xxl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xxl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xxl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xxl-center {\n justify-content: center !important;\n }\n .justify-content-xxl-between {\n justify-content: space-between !important;\n }\n .justify-content-xxl-around {\n justify-content: space-around !important;\n }\n .justify-content-xxl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xxl-start {\n align-items: flex-start !important;\n }\n .align-items-xxl-end {\n align-items: flex-end !important;\n }\n .align-items-xxl-center {\n align-items: center !important;\n }\n .align-items-xxl-baseline {\n align-items: baseline !important;\n }\n .align-items-xxl-stretch {\n align-items: stretch !important;\n }\n .align-content-xxl-start {\n align-content: flex-start !important;\n }\n .align-content-xxl-end {\n align-content: flex-end !important;\n }\n .align-content-xxl-center {\n align-content: center !important;\n }\n .align-content-xxl-between {\n align-content: space-between !important;\n }\n .align-content-xxl-around {\n align-content: space-around !important;\n }\n .align-content-xxl-stretch {\n align-content: stretch !important;\n }\n .align-self-xxl-auto {\n align-self: auto !important;\n }\n .align-self-xxl-start {\n align-self: flex-start !important;\n }\n .align-self-xxl-end {\n align-self: flex-end !important;\n }\n .align-self-xxl-center {\n align-self: center !important;\n }\n .align-self-xxl-baseline {\n align-self: baseline !important;\n }\n .align-self-xxl-stretch {\n align-self: stretch !important;\n }\n .order-xxl-first {\n order: -1 !important;\n }\n .order-xxl-0 {\n order: 0 !important;\n }\n .order-xxl-1 {\n order: 1 !important;\n }\n .order-xxl-2 {\n order: 2 !important;\n }\n .order-xxl-3 {\n order: 3 !important;\n }\n .order-xxl-4 {\n order: 4 !important;\n }\n .order-xxl-5 {\n order: 5 !important;\n }\n .order-xxl-last {\n order: 6 !important;\n }\n .m-xxl-0 {\n margin: 0 !important;\n }\n .m-xxl-1 {\n margin: 0.25rem !important;\n }\n .m-xxl-2 {\n margin: 0.5rem !important;\n }\n .m-xxl-3 {\n margin: 1rem !important;\n }\n .m-xxl-4 {\n margin: 1.5rem !important;\n }\n .m-xxl-5 {\n margin: 3rem !important;\n }\n .m-xxl-auto {\n margin: auto !important;\n }\n .mx-xxl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xxl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xxl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xxl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xxl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xxl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xxl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xxl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xxl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xxl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xxl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xxl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xxl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xxl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xxl-0 {\n margin-top: 0 !important;\n }\n .mt-xxl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xxl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xxl-3 {\n margin-top: 1rem !important;\n }\n .mt-xxl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xxl-5 {\n margin-top: 3rem !important;\n }\n .mt-xxl-auto {\n margin-top: auto !important;\n }\n .me-xxl-0 {\n margin-right: 0 !important;\n }\n .me-xxl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xxl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xxl-3 {\n margin-right: 1rem !important;\n }\n .me-xxl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xxl-5 {\n margin-right: 3rem !important;\n }\n .me-xxl-auto {\n margin-right: auto !important;\n }\n .mb-xxl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xxl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xxl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xxl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xxl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xxl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xxl-auto {\n margin-bottom: auto !important;\n }\n .ms-xxl-0 {\n margin-left: 0 !important;\n }\n .ms-xxl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xxl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xxl-3 {\n margin-left: 1rem !important;\n }\n .ms-xxl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xxl-5 {\n margin-left: 3rem !important;\n }\n .ms-xxl-auto {\n margin-left: auto !important;\n }\n .p-xxl-0 {\n padding: 0 !important;\n }\n .p-xxl-1 {\n padding: 0.25rem !important;\n }\n .p-xxl-2 {\n padding: 0.5rem !important;\n }\n .p-xxl-3 {\n padding: 1rem !important;\n }\n .p-xxl-4 {\n padding: 1.5rem !important;\n }\n .p-xxl-5 {\n padding: 3rem !important;\n }\n .px-xxl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xxl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xxl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xxl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xxl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xxl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xxl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xxl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xxl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xxl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xxl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xxl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xxl-0 {\n padding-top: 0 !important;\n }\n .pt-xxl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xxl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xxl-3 {\n padding-top: 1rem !important;\n }\n .pt-xxl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xxl-5 {\n padding-top: 3rem !important;\n }\n .pe-xxl-0 {\n padding-right: 0 !important;\n }\n .pe-xxl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xxl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xxl-3 {\n padding-right: 1rem !important;\n }\n .pe-xxl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xxl-5 {\n padding-right: 3rem !important;\n }\n .pb-xxl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xxl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xxl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xxl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xxl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xxl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xxl-0 {\n padding-left: 0 !important;\n }\n .ps-xxl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xxl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xxl-3 {\n padding-left: 1rem !important;\n }\n .ps-xxl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xxl-5 {\n padding-left: 3rem !important;\n }\n}\n@media print {\n .d-print-inline {\n display: inline !important;\n }\n .d-print-inline-block {\n display: inline-block !important;\n }\n .d-print-block {\n display: block !important;\n }\n .d-print-grid {\n display: grid !important;\n }\n .d-print-inline-grid {\n display: inline-grid !important;\n }\n .d-print-table {\n display: table !important;\n }\n .d-print-table-row {\n display: table-row !important;\n }\n .d-print-table-cell {\n display: table-cell !important;\n }\n .d-print-flex {\n display: flex !important;\n }\n .d-print-inline-flex {\n display: inline-flex !important;\n }\n .d-print-none {\n display: none !important;\n }\n}\n\n/*# sourceMappingURL=bootstrap-grid.css.map */\n","// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)\n//\n// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n// >> breakpoint-next(sm)\n// md\n// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// md\n// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl))\n// md\n@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {\n $n: index($breakpoint-names, $name);\n @if not $n {\n @error \"breakpoint `#{$name}` not found in `#{$breakpoints}`\";\n }\n @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 576px\n@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {\n $min: map-get($breakpoints, $name);\n @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width.\n// The maximum value is reduced by 0.02px to work around the limitations of\n// `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 767.98px\n@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {\n $max: map-get($breakpoints, $name);\n @return if($max and $max > 0, $max - .02, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.\n// Useful for making responsive utilities.\n//\n// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"\" (Returns a blank string)\n// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"-sm\"\n@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {\n @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n @if $min {\n @media (min-width: $min) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {\n $max: breakpoint-max($name, $breakpoints);\n @if $max {\n @media (max-width: $max) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($lower, $breakpoints);\n $max: breakpoint-max($upper, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($lower, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($upper, $breakpoints) {\n @content;\n }\n }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n $next: breakpoint-next($name, $breakpoints);\n $max: breakpoint-max($next, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($name, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($next, $breakpoints) {\n @content;\n }\n }\n}\n","// Variables\n//\n// Variables should follow the `$component-state-property-size` formula for\n// consistent naming. Ex: $nav-link-disabled-color and $modal-content-box-shadow-xs.\n\n// Color system\n\n// scss-docs-start gray-color-variables\n$white: #fff !default;\n$gray-100: #f8f9fa !default;\n$gray-200: #e9ecef !default;\n$gray-300: #dee2e6 !default;\n$gray-400: #ced4da !default;\n$gray-500: #adb5bd !default;\n$gray-600: #6c757d !default;\n$gray-700: #495057 !default;\n$gray-800: #343a40 !default;\n$gray-900: #212529 !default;\n$black: #000 !default;\n// scss-docs-end gray-color-variables\n\n// fusv-disable\n// scss-docs-start gray-colors-map\n$grays: (\n \"100\": $gray-100,\n \"200\": $gray-200,\n \"300\": $gray-300,\n \"400\": $gray-400,\n \"500\": $gray-500,\n \"600\": $gray-600,\n \"700\": $gray-700,\n \"800\": $gray-800,\n \"900\": $gray-900\n) !default;\n// scss-docs-end gray-colors-map\n// fusv-enable\n\n// scss-docs-start color-variables\n$blue: #0d6efd !default;\n$indigo: #6610f2 !default;\n$purple: #6f42c1 !default;\n$pink: #d63384 !default;\n$red: #dc3545 !default;\n$orange: #fd7e14 !default;\n$yellow: #ffc107 !default;\n$green: #198754 !default;\n$teal: #20c997 !default;\n$cyan: #0dcaf0 !default;\n// scss-docs-end color-variables\n\n// scss-docs-start colors-map\n$colors: (\n \"blue\": $blue,\n \"indigo\": $indigo,\n \"purple\": $purple,\n \"pink\": $pink,\n \"red\": $red,\n \"orange\": $orange,\n \"yellow\": $yellow,\n \"green\": $green,\n \"teal\": $teal,\n \"cyan\": $cyan,\n \"black\": $black,\n \"white\": $white,\n \"gray\": $gray-600,\n \"gray-dark\": $gray-800\n) !default;\n// scss-docs-end colors-map\n\n// The contrast ratio to reach against white, to determine if color changes from \"light\" to \"dark\". Acceptable values for WCAG 2.0 are 3, 4.5 and 7.\n// See https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast\n$min-contrast-ratio: 4.5 !default;\n\n// Customize the light and dark text colors for use in our color contrast function.\n$color-contrast-dark: $black !default;\n$color-contrast-light: $white !default;\n\n// fusv-disable\n$blue-100: tint-color($blue, 80%) !default;\n$blue-200: tint-color($blue, 60%) !default;\n$blue-300: tint-color($blue, 40%) !default;\n$blue-400: tint-color($blue, 20%) !default;\n$blue-500: $blue !default;\n$blue-600: shade-color($blue, 20%) !default;\n$blue-700: shade-color($blue, 40%) !default;\n$blue-800: shade-color($blue, 60%) !default;\n$blue-900: shade-color($blue, 80%) !default;\n\n$indigo-100: tint-color($indigo, 80%) !default;\n$indigo-200: tint-color($indigo, 60%) !default;\n$indigo-300: tint-color($indigo, 40%) !default;\n$indigo-400: tint-color($indigo, 20%) !default;\n$indigo-500: $indigo !default;\n$indigo-600: shade-color($indigo, 20%) !default;\n$indigo-700: shade-color($indigo, 40%) !default;\n$indigo-800: shade-color($indigo, 60%) !default;\n$indigo-900: shade-color($indigo, 80%) !default;\n\n$purple-100: tint-color($purple, 80%) !default;\n$purple-200: tint-color($purple, 60%) !default;\n$purple-300: tint-color($purple, 40%) !default;\n$purple-400: tint-color($purple, 20%) !default;\n$purple-500: $purple !default;\n$purple-600: shade-color($purple, 20%) !default;\n$purple-700: shade-color($purple, 40%) !default;\n$purple-800: shade-color($purple, 60%) !default;\n$purple-900: shade-color($purple, 80%) !default;\n\n$pink-100: tint-color($pink, 80%) !default;\n$pink-200: tint-color($pink, 60%) !default;\n$pink-300: tint-color($pink, 40%) !default;\n$pink-400: tint-color($pink, 20%) !default;\n$pink-500: $pink !default;\n$pink-600: shade-color($pink, 20%) !default;\n$pink-700: shade-color($pink, 40%) !default;\n$pink-800: shade-color($pink, 60%) !default;\n$pink-900: shade-color($pink, 80%) !default;\n\n$red-100: tint-color($red, 80%) !default;\n$red-200: tint-color($red, 60%) !default;\n$red-300: tint-color($red, 40%) !default;\n$red-400: tint-color($red, 20%) !default;\n$red-500: $red !default;\n$red-600: shade-color($red, 20%) !default;\n$red-700: shade-color($red, 40%) !default;\n$red-800: shade-color($red, 60%) !default;\n$red-900: shade-color($red, 80%) !default;\n\n$orange-100: tint-color($orange, 80%) !default;\n$orange-200: tint-color($orange, 60%) !default;\n$orange-300: tint-color($orange, 40%) !default;\n$orange-400: tint-color($orange, 20%) !default;\n$orange-500: $orange !default;\n$orange-600: shade-color($orange, 20%) !default;\n$orange-700: shade-color($orange, 40%) !default;\n$orange-800: shade-color($orange, 60%) !default;\n$orange-900: shade-color($orange, 80%) !default;\n\n$yellow-100: tint-color($yellow, 80%) !default;\n$yellow-200: tint-color($yellow, 60%) !default;\n$yellow-300: tint-color($yellow, 40%) !default;\n$yellow-400: tint-color($yellow, 20%) !default;\n$yellow-500: $yellow !default;\n$yellow-600: shade-color($yellow, 20%) !default;\n$yellow-700: shade-color($yellow, 40%) !default;\n$yellow-800: shade-color($yellow, 60%) !default;\n$yellow-900: shade-color($yellow, 80%) !default;\n\n$green-100: tint-color($green, 80%) !default;\n$green-200: tint-color($green, 60%) !default;\n$green-300: tint-color($green, 40%) !default;\n$green-400: tint-color($green, 20%) !default;\n$green-500: $green !default;\n$green-600: shade-color($green, 20%) !default;\n$green-700: shade-color($green, 40%) !default;\n$green-800: shade-color($green, 60%) !default;\n$green-900: shade-color($green, 80%) !default;\n\n$teal-100: tint-color($teal, 80%) !default;\n$teal-200: tint-color($teal, 60%) !default;\n$teal-300: tint-color($teal, 40%) !default;\n$teal-400: tint-color($teal, 20%) !default;\n$teal-500: $teal !default;\n$teal-600: shade-color($teal, 20%) !default;\n$teal-700: shade-color($teal, 40%) !default;\n$teal-800: shade-color($teal, 60%) !default;\n$teal-900: shade-color($teal, 80%) !default;\n\n$cyan-100: tint-color($cyan, 80%) !default;\n$cyan-200: tint-color($cyan, 60%) !default;\n$cyan-300: tint-color($cyan, 40%) !default;\n$cyan-400: tint-color($cyan, 20%) !default;\n$cyan-500: $cyan !default;\n$cyan-600: shade-color($cyan, 20%) !default;\n$cyan-700: shade-color($cyan, 40%) !default;\n$cyan-800: shade-color($cyan, 60%) !default;\n$cyan-900: shade-color($cyan, 80%) !default;\n\n$blues: (\n \"blue-100\": $blue-100,\n \"blue-200\": $blue-200,\n \"blue-300\": $blue-300,\n \"blue-400\": $blue-400,\n \"blue-500\": $blue-500,\n \"blue-600\": $blue-600,\n \"blue-700\": $blue-700,\n \"blue-800\": $blue-800,\n \"blue-900\": $blue-900\n) !default;\n\n$indigos: (\n \"indigo-100\": $indigo-100,\n \"indigo-200\": $indigo-200,\n \"indigo-300\": $indigo-300,\n \"indigo-400\": $indigo-400,\n \"indigo-500\": $indigo-500,\n \"indigo-600\": $indigo-600,\n \"indigo-700\": $indigo-700,\n \"indigo-800\": $indigo-800,\n \"indigo-900\": $indigo-900\n) !default;\n\n$purples: (\n \"purple-100\": $purple-100,\n \"purple-200\": $purple-200,\n \"purple-300\": $purple-300,\n \"purple-400\": $purple-400,\n \"purple-500\": $purple-500,\n \"purple-600\": $purple-600,\n \"purple-700\": $purple-700,\n \"purple-800\": $purple-800,\n \"purple-900\": $purple-900\n) !default;\n\n$pinks: (\n \"pink-100\": $pink-100,\n \"pink-200\": $pink-200,\n \"pink-300\": $pink-300,\n \"pink-400\": $pink-400,\n \"pink-500\": $pink-500,\n \"pink-600\": $pink-600,\n \"pink-700\": $pink-700,\n \"pink-800\": $pink-800,\n \"pink-900\": $pink-900\n) !default;\n\n$reds: (\n \"red-100\": $red-100,\n \"red-200\": $red-200,\n \"red-300\": $red-300,\n \"red-400\": $red-400,\n \"red-500\": $red-500,\n \"red-600\": $red-600,\n \"red-700\": $red-700,\n \"red-800\": $red-800,\n \"red-900\": $red-900\n) !default;\n\n$oranges: (\n \"orange-100\": $orange-100,\n \"orange-200\": $orange-200,\n \"orange-300\": $orange-300,\n \"orange-400\": $orange-400,\n \"orange-500\": $orange-500,\n \"orange-600\": $orange-600,\n \"orange-700\": $orange-700,\n \"orange-800\": $orange-800,\n \"orange-900\": $orange-900\n) !default;\n\n$yellows: (\n \"yellow-100\": $yellow-100,\n \"yellow-200\": $yellow-200,\n \"yellow-300\": $yellow-300,\n \"yellow-400\": $yellow-400,\n \"yellow-500\": $yellow-500,\n \"yellow-600\": $yellow-600,\n \"yellow-700\": $yellow-700,\n \"yellow-800\": $yellow-800,\n \"yellow-900\": $yellow-900\n) !default;\n\n$greens: (\n \"green-100\": $green-100,\n \"green-200\": $green-200,\n \"green-300\": $green-300,\n \"green-400\": $green-400,\n \"green-500\": $green-500,\n \"green-600\": $green-600,\n \"green-700\": $green-700,\n \"green-800\": $green-800,\n \"green-900\": $green-900\n) !default;\n\n$teals: (\n \"teal-100\": $teal-100,\n \"teal-200\": $teal-200,\n \"teal-300\": $teal-300,\n \"teal-400\": $teal-400,\n \"teal-500\": $teal-500,\n \"teal-600\": $teal-600,\n \"teal-700\": $teal-700,\n \"teal-800\": $teal-800,\n \"teal-900\": $teal-900\n) !default;\n\n$cyans: (\n \"cyan-100\": $cyan-100,\n \"cyan-200\": $cyan-200,\n \"cyan-300\": $cyan-300,\n \"cyan-400\": $cyan-400,\n \"cyan-500\": $cyan-500,\n \"cyan-600\": $cyan-600,\n \"cyan-700\": $cyan-700,\n \"cyan-800\": $cyan-800,\n \"cyan-900\": $cyan-900\n) !default;\n// fusv-enable\n\n// scss-docs-start theme-color-variables\n$primary: $blue !default;\n$secondary: $gray-600 !default;\n$success: $green !default;\n$info: $cyan !default;\n$warning: $yellow !default;\n$danger: $red !default;\n$light: $gray-100 !default;\n$dark: $gray-900 !default;\n// scss-docs-end theme-color-variables\n\n// scss-docs-start theme-colors-map\n$theme-colors: (\n \"primary\": $primary,\n \"secondary\": $secondary,\n \"success\": $success,\n \"info\": $info,\n \"warning\": $warning,\n \"danger\": $danger,\n \"light\": $light,\n \"dark\": $dark\n) !default;\n// scss-docs-end theme-colors-map\n\n// scss-docs-start theme-text-variables\n$primary-text-emphasis: shade-color($primary, 60%) !default;\n$secondary-text-emphasis: shade-color($secondary, 60%) !default;\n$success-text-emphasis: shade-color($success, 60%) !default;\n$info-text-emphasis: shade-color($info, 60%) !default;\n$warning-text-emphasis: shade-color($warning, 60%) !default;\n$danger-text-emphasis: shade-color($danger, 60%) !default;\n$light-text-emphasis: $gray-700 !default;\n$dark-text-emphasis: $gray-700 !default;\n// scss-docs-end theme-text-variables\n\n// scss-docs-start theme-bg-subtle-variables\n$primary-bg-subtle: tint-color($primary, 80%) !default;\n$secondary-bg-subtle: tint-color($secondary, 80%) !default;\n$success-bg-subtle: tint-color($success, 80%) !default;\n$info-bg-subtle: tint-color($info, 80%) !default;\n$warning-bg-subtle: tint-color($warning, 80%) !default;\n$danger-bg-subtle: tint-color($danger, 80%) !default;\n$light-bg-subtle: mix($gray-100, $white) !default;\n$dark-bg-subtle: $gray-400 !default;\n// scss-docs-end theme-bg-subtle-variables\n\n// scss-docs-start theme-border-subtle-variables\n$primary-border-subtle: tint-color($primary, 60%) !default;\n$secondary-border-subtle: tint-color($secondary, 60%) !default;\n$success-border-subtle: tint-color($success, 60%) !default;\n$info-border-subtle: tint-color($info, 60%) !default;\n$warning-border-subtle: tint-color($warning, 60%) !default;\n$danger-border-subtle: tint-color($danger, 60%) !default;\n$light-border-subtle: $gray-200 !default;\n$dark-border-subtle: $gray-500 !default;\n// scss-docs-end theme-border-subtle-variables\n\n// Characters which are escaped by the escape-svg function\n$escaped-characters: (\n (\"<\", \"%3c\"),\n (\">\", \"%3e\"),\n (\"#\", \"%23\"),\n (\"(\", \"%28\"),\n (\")\", \"%29\"),\n) !default;\n\n// Options\n//\n// Quickly modify global styling by enabling or disabling optional features.\n\n$enable-caret: true !default;\n$enable-rounded: true !default;\n$enable-shadows: false !default;\n$enable-gradients: false !default;\n$enable-transitions: true !default;\n$enable-reduced-motion: true !default;\n$enable-smooth-scroll: true !default;\n$enable-grid-classes: true !default;\n$enable-container-classes: true !default;\n$enable-cssgrid: false !default;\n$enable-button-pointers: true !default;\n$enable-rfs: true !default;\n$enable-validation-icons: true !default;\n$enable-negative-margins: false !default;\n$enable-deprecation-messages: true !default;\n$enable-important-utilities: true !default;\n\n$enable-dark-mode: true !default;\n$color-mode-type: data !default; // `data` or `media-query`\n\n// Prefix for :root CSS variables\n\n$variable-prefix: bs- !default; // Deprecated in v5.2.0 for the shorter `$prefix`\n$prefix: $variable-prefix !default;\n\n// Gradient\n//\n// The gradient which is added to components if `$enable-gradients` is `true`\n// This gradient is also added to elements with `.bg-gradient`\n// scss-docs-start variable-gradient\n$gradient: linear-gradient(180deg, rgba($white, .15), rgba($white, 0)) !default;\n// scss-docs-end variable-gradient\n\n// Spacing\n//\n// Control the default styling of most Bootstrap elements by modifying these\n// variables. Mostly focused on spacing.\n// You can add more entries to the $spacers map, should you need more variation.\n\n// scss-docs-start spacer-variables-maps\n$spacer: 1rem !default;\n$spacers: (\n 0: 0,\n 1: $spacer * .25,\n 2: $spacer * .5,\n 3: $spacer,\n 4: $spacer * 1.5,\n 5: $spacer * 3,\n) !default;\n// scss-docs-end spacer-variables-maps\n\n// Position\n//\n// Define the edge positioning anchors of the position utilities.\n\n// scss-docs-start position-map\n$position-values: (\n 0: 0,\n 50: 50%,\n 100: 100%\n) !default;\n// scss-docs-end position-map\n\n// Body\n//\n// Settings for the `` element.\n\n$body-text-align: null !default;\n$body-color: $gray-900 !default;\n$body-bg: $white !default;\n\n$body-secondary-color: rgba($body-color, .75) !default;\n$body-secondary-bg: $gray-200 !default;\n\n$body-tertiary-color: rgba($body-color, .5) !default;\n$body-tertiary-bg: $gray-100 !default;\n\n$body-emphasis-color: $black !default;\n\n// Links\n//\n// Style anchor elements.\n\n$link-color: $primary !default;\n$link-decoration: underline !default;\n$link-shade-percentage: 20% !default;\n$link-hover-color: shift-color($link-color, $link-shade-percentage) !default;\n$link-hover-decoration: null !default;\n\n$stretched-link-pseudo-element: after !default;\n$stretched-link-z-index: 1 !default;\n\n// Icon links\n// scss-docs-start icon-link-variables\n$icon-link-gap: .375rem !default;\n$icon-link-underline-offset: .25em !default;\n$icon-link-icon-size: 1em !default;\n$icon-link-icon-transition: .2s ease-in-out transform !default;\n$icon-link-icon-transform: translate3d(.25em, 0, 0) !default;\n// scss-docs-end icon-link-variables\n\n// Paragraphs\n//\n// Style p element.\n\n$paragraph-margin-bottom: 1rem !default;\n\n\n// Grid breakpoints\n//\n// Define the minimum dimensions at which your layout will change,\n// adapting to different screen sizes, for use in media queries.\n\n// scss-docs-start grid-breakpoints\n$grid-breakpoints: (\n xs: 0,\n sm: 576px,\n md: 768px,\n lg: 992px,\n xl: 1200px,\n xxl: 1400px\n) !default;\n// scss-docs-end grid-breakpoints\n\n@include _assert-ascending($grid-breakpoints, \"$grid-breakpoints\");\n@include _assert-starts-at-zero($grid-breakpoints, \"$grid-breakpoints\");\n\n\n// Grid containers\n//\n// Define the maximum width of `.container` for different screen sizes.\n\n// scss-docs-start container-max-widths\n$container-max-widths: (\n sm: 540px,\n md: 720px,\n lg: 960px,\n xl: 1140px,\n xxl: 1320px\n) !default;\n// scss-docs-end container-max-widths\n\n@include _assert-ascending($container-max-widths, \"$container-max-widths\");\n\n\n// Grid columns\n//\n// Set the number of columns and specify the width of the gutters.\n\n$grid-columns: 12 !default;\n$grid-gutter-width: 1.5rem !default;\n$grid-row-columns: 6 !default;\n\n// Container padding\n\n$container-padding-x: $grid-gutter-width !default;\n\n\n// Components\n//\n// Define common padding and border radius sizes and more.\n\n// scss-docs-start border-variables\n$border-width: 1px !default;\n$border-widths: (\n 1: 1px,\n 2: 2px,\n 3: 3px,\n 4: 4px,\n 5: 5px\n) !default;\n$border-style: solid !default;\n$border-color: $gray-300 !default;\n$border-color-translucent: rgba($black, .175) !default;\n// scss-docs-end border-variables\n\n// scss-docs-start border-radius-variables\n$border-radius: .375rem !default;\n$border-radius-sm: .25rem !default;\n$border-radius-lg: .5rem !default;\n$border-radius-xl: 1rem !default;\n$border-radius-xxl: 2rem !default;\n$border-radius-pill: 50rem !default;\n// scss-docs-end border-radius-variables\n// fusv-disable\n$border-radius-2xl: $border-radius-xxl !default; // Deprecated in v5.3.0\n// fusv-enable\n\n// scss-docs-start box-shadow-variables\n$box-shadow: 0 .5rem 1rem rgba($black, .15) !default;\n$box-shadow-sm: 0 .125rem .25rem rgba($black, .075) !default;\n$box-shadow-lg: 0 1rem 3rem rgba($black, .175) !default;\n$box-shadow-inset: inset 0 1px 2px rgba($black, .075) !default;\n// scss-docs-end box-shadow-variables\n\n$component-active-color: $white !default;\n$component-active-bg: $primary !default;\n\n// scss-docs-start focus-ring-variables\n$focus-ring-width: .25rem !default;\n$focus-ring-opacity: .25 !default;\n$focus-ring-color: rgba($primary, $focus-ring-opacity) !default;\n$focus-ring-blur: 0 !default;\n$focus-ring-box-shadow: 0 0 $focus-ring-blur $focus-ring-width $focus-ring-color !default;\n// scss-docs-end focus-ring-variables\n\n// scss-docs-start caret-variables\n$caret-width: .3em !default;\n$caret-vertical-align: $caret-width * .85 !default;\n$caret-spacing: $caret-width * .85 !default;\n// scss-docs-end caret-variables\n\n$transition-base: all .2s ease-in-out !default;\n$transition-fade: opacity .15s linear !default;\n// scss-docs-start collapse-transition\n$transition-collapse: height .35s ease !default;\n$transition-collapse-width: width .35s ease !default;\n// scss-docs-end collapse-transition\n\n// stylelint-disable function-disallowed-list\n// scss-docs-start aspect-ratios\n$aspect-ratios: (\n \"1x1\": 100%,\n \"4x3\": calc(3 / 4 * 100%),\n \"16x9\": calc(9 / 16 * 100%),\n \"21x9\": calc(9 / 21 * 100%)\n) !default;\n// scss-docs-end aspect-ratios\n// stylelint-enable function-disallowed-list\n\n// Typography\n//\n// Font, line-height, and color for body text, headings, and more.\n\n// scss-docs-start font-variables\n// stylelint-disable value-keyword-case\n$font-family-sans-serif: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\" !default;\n$font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace !default;\n// stylelint-enable value-keyword-case\n$font-family-base: var(--#{$prefix}font-sans-serif) !default;\n$font-family-code: var(--#{$prefix}font-monospace) !default;\n\n// $font-size-root affects the value of `rem`, which is used for as well font sizes, paddings, and margins\n// $font-size-base affects the font size of the body text\n$font-size-root: null !default;\n$font-size-base: 1rem !default; // Assumes the browser default, typically `16px`\n$font-size-sm: $font-size-base * .875 !default;\n$font-size-lg: $font-size-base * 1.25 !default;\n\n$font-weight-lighter: lighter !default;\n$font-weight-light: 300 !default;\n$font-weight-normal: 400 !default;\n$font-weight-medium: 500 !default;\n$font-weight-semibold: 600 !default;\n$font-weight-bold: 700 !default;\n$font-weight-bolder: bolder !default;\n\n$font-weight-base: $font-weight-normal !default;\n\n$line-height-base: 1.5 !default;\n$line-height-sm: 1.25 !default;\n$line-height-lg: 2 !default;\n\n$h1-font-size: $font-size-base * 2.5 !default;\n$h2-font-size: $font-size-base * 2 !default;\n$h3-font-size: $font-size-base * 1.75 !default;\n$h4-font-size: $font-size-base * 1.5 !default;\n$h5-font-size: $font-size-base * 1.25 !default;\n$h6-font-size: $font-size-base !default;\n// scss-docs-end font-variables\n\n// scss-docs-start font-sizes\n$font-sizes: (\n 1: $h1-font-size,\n 2: $h2-font-size,\n 3: $h3-font-size,\n 4: $h4-font-size,\n 5: $h5-font-size,\n 6: $h6-font-size\n) !default;\n// scss-docs-end font-sizes\n\n// scss-docs-start headings-variables\n$headings-margin-bottom: $spacer * .5 !default;\n$headings-font-family: null !default;\n$headings-font-style: null !default;\n$headings-font-weight: 500 !default;\n$headings-line-height: 1.2 !default;\n$headings-color: inherit !default;\n// scss-docs-end headings-variables\n\n// scss-docs-start display-headings\n$display-font-sizes: (\n 1: 5rem,\n 2: 4.5rem,\n 3: 4rem,\n 4: 3.5rem,\n 5: 3rem,\n 6: 2.5rem\n) !default;\n\n$display-font-family: null !default;\n$display-font-style: null !default;\n$display-font-weight: 300 !default;\n$display-line-height: $headings-line-height !default;\n// scss-docs-end display-headings\n\n// scss-docs-start type-variables\n$lead-font-size: $font-size-base * 1.25 !default;\n$lead-font-weight: 300 !default;\n\n$small-font-size: .875em !default;\n\n$sub-sup-font-size: .75em !default;\n\n// fusv-disable\n$text-muted: var(--#{$prefix}secondary-color) !default; // Deprecated in 5.3.0\n// fusv-enable\n\n$initialism-font-size: $small-font-size !default;\n\n$blockquote-margin-y: $spacer !default;\n$blockquote-font-size: $font-size-base * 1.25 !default;\n$blockquote-footer-color: $gray-600 !default;\n$blockquote-footer-font-size: $small-font-size !default;\n\n$hr-margin-y: $spacer !default;\n$hr-color: inherit !default;\n\n// fusv-disable\n$hr-bg-color: null !default; // Deprecated in v5.2.0\n$hr-height: null !default; // Deprecated in v5.2.0\n// fusv-enable\n\n$hr-border-color: null !default; // Allows for inherited colors\n$hr-border-width: var(--#{$prefix}border-width) !default;\n$hr-opacity: .25 !default;\n\n// scss-docs-start vr-variables\n$vr-border-width: var(--#{$prefix}border-width) !default;\n// scss-docs-end vr-variables\n\n$legend-margin-bottom: .5rem !default;\n$legend-font-size: 1.5rem !default;\n$legend-font-weight: null !default;\n\n$dt-font-weight: $font-weight-bold !default;\n\n$list-inline-padding: .5rem !default;\n\n$mark-padding: .1875em !default;\n$mark-color: $body-color !default;\n$mark-bg: $yellow-100 !default;\n// scss-docs-end type-variables\n\n\n// Tables\n//\n// Customizes the `.table` component with basic values, each used across all table variations.\n\n// scss-docs-start table-variables\n$table-cell-padding-y: .5rem !default;\n$table-cell-padding-x: .5rem !default;\n$table-cell-padding-y-sm: .25rem !default;\n$table-cell-padding-x-sm: .25rem !default;\n\n$table-cell-vertical-align: top !default;\n\n$table-color: var(--#{$prefix}emphasis-color) !default;\n$table-bg: var(--#{$prefix}body-bg) !default;\n$table-accent-bg: transparent !default;\n\n$table-th-font-weight: null !default;\n\n$table-striped-color: $table-color !default;\n$table-striped-bg-factor: .05 !default;\n$table-striped-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-striped-bg-factor) !default;\n\n$table-active-color: $table-color !default;\n$table-active-bg-factor: .1 !default;\n$table-active-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-active-bg-factor) !default;\n\n$table-hover-color: $table-color !default;\n$table-hover-bg-factor: .075 !default;\n$table-hover-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-hover-bg-factor) !default;\n\n$table-border-factor: .2 !default;\n$table-border-width: var(--#{$prefix}border-width) !default;\n$table-border-color: var(--#{$prefix}border-color) !default;\n\n$table-striped-order: odd !default;\n$table-striped-columns-order: even !default;\n\n$table-group-separator-color: currentcolor !default;\n\n$table-caption-color: var(--#{$prefix}secondary-color) !default;\n\n$table-bg-scale: -80% !default;\n// scss-docs-end table-variables\n\n// scss-docs-start table-loop\n$table-variants: (\n \"primary\": shift-color($primary, $table-bg-scale),\n \"secondary\": shift-color($secondary, $table-bg-scale),\n \"success\": shift-color($success, $table-bg-scale),\n \"info\": shift-color($info, $table-bg-scale),\n \"warning\": shift-color($warning, $table-bg-scale),\n \"danger\": shift-color($danger, $table-bg-scale),\n \"light\": $light,\n \"dark\": $dark,\n) !default;\n// scss-docs-end table-loop\n\n\n// Buttons + Forms\n//\n// Shared variables that are reassigned to `$input-` and `$btn-` specific variables.\n\n// scss-docs-start input-btn-variables\n$input-btn-padding-y: .375rem !default;\n$input-btn-padding-x: .75rem !default;\n$input-btn-font-family: null !default;\n$input-btn-font-size: $font-size-base !default;\n$input-btn-line-height: $line-height-base !default;\n\n$input-btn-focus-width: $focus-ring-width !default;\n$input-btn-focus-color-opacity: $focus-ring-opacity !default;\n$input-btn-focus-color: $focus-ring-color !default;\n$input-btn-focus-blur: $focus-ring-blur !default;\n$input-btn-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$input-btn-padding-y-sm: .25rem !default;\n$input-btn-padding-x-sm: .5rem !default;\n$input-btn-font-size-sm: $font-size-sm !default;\n\n$input-btn-padding-y-lg: .5rem !default;\n$input-btn-padding-x-lg: 1rem !default;\n$input-btn-font-size-lg: $font-size-lg !default;\n\n$input-btn-border-width: var(--#{$prefix}border-width) !default;\n// scss-docs-end input-btn-variables\n\n\n// Buttons\n//\n// For each of Bootstrap's buttons, define text, background, and border color.\n\n// scss-docs-start btn-variables\n$btn-color: var(--#{$prefix}body-color) !default;\n$btn-padding-y: $input-btn-padding-y !default;\n$btn-padding-x: $input-btn-padding-x !default;\n$btn-font-family: $input-btn-font-family !default;\n$btn-font-size: $input-btn-font-size !default;\n$btn-line-height: $input-btn-line-height !default;\n$btn-white-space: null !default; // Set to `nowrap` to prevent text wrapping\n\n$btn-padding-y-sm: $input-btn-padding-y-sm !default;\n$btn-padding-x-sm: $input-btn-padding-x-sm !default;\n$btn-font-size-sm: $input-btn-font-size-sm !default;\n\n$btn-padding-y-lg: $input-btn-padding-y-lg !default;\n$btn-padding-x-lg: $input-btn-padding-x-lg !default;\n$btn-font-size-lg: $input-btn-font-size-lg !default;\n\n$btn-border-width: $input-btn-border-width !default;\n\n$btn-font-weight: $font-weight-normal !default;\n$btn-box-shadow: inset 0 1px 0 rgba($white, .15), 0 1px 1px rgba($black, .075) !default;\n$btn-focus-width: $input-btn-focus-width !default;\n$btn-focus-box-shadow: $input-btn-focus-box-shadow !default;\n$btn-disabled-opacity: .65 !default;\n$btn-active-box-shadow: inset 0 3px 5px rgba($black, .125) !default;\n\n$btn-link-color: var(--#{$prefix}link-color) !default;\n$btn-link-hover-color: var(--#{$prefix}link-hover-color) !default;\n$btn-link-disabled-color: $gray-600 !default;\n$btn-link-focus-shadow-rgb: to-rgb(mix(color-contrast($link-color), $link-color, 15%)) !default;\n\n// Allows for customizing button radius independently from global border radius\n$btn-border-radius: var(--#{$prefix}border-radius) !default;\n$btn-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$btn-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n\n$btn-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$btn-hover-bg-shade-amount: 15% !default;\n$btn-hover-bg-tint-amount: 15% !default;\n$btn-hover-border-shade-amount: 20% !default;\n$btn-hover-border-tint-amount: 10% !default;\n$btn-active-bg-shade-amount: 20% !default;\n$btn-active-bg-tint-amount: 20% !default;\n$btn-active-border-shade-amount: 25% !default;\n$btn-active-border-tint-amount: 10% !default;\n// scss-docs-end btn-variables\n\n\n// Forms\n\n// scss-docs-start form-text-variables\n$form-text-margin-top: .25rem !default;\n$form-text-font-size: $small-font-size !default;\n$form-text-font-style: null !default;\n$form-text-font-weight: null !default;\n$form-text-color: var(--#{$prefix}secondary-color) !default;\n// scss-docs-end form-text-variables\n\n// scss-docs-start form-label-variables\n$form-label-margin-bottom: .5rem !default;\n$form-label-font-size: null !default;\n$form-label-font-style: null !default;\n$form-label-font-weight: null !default;\n$form-label-color: null !default;\n// scss-docs-end form-label-variables\n\n// scss-docs-start form-input-variables\n$input-padding-y: $input-btn-padding-y !default;\n$input-padding-x: $input-btn-padding-x !default;\n$input-font-family: $input-btn-font-family !default;\n$input-font-size: $input-btn-font-size !default;\n$input-font-weight: $font-weight-base !default;\n$input-line-height: $input-btn-line-height !default;\n\n$input-padding-y-sm: $input-btn-padding-y-sm !default;\n$input-padding-x-sm: $input-btn-padding-x-sm !default;\n$input-font-size-sm: $input-btn-font-size-sm !default;\n\n$input-padding-y-lg: $input-btn-padding-y-lg !default;\n$input-padding-x-lg: $input-btn-padding-x-lg !default;\n$input-font-size-lg: $input-btn-font-size-lg !default;\n\n$input-bg: var(--#{$prefix}body-bg) !default;\n$input-disabled-color: null !default;\n$input-disabled-bg: var(--#{$prefix}secondary-bg) !default;\n$input-disabled-border-color: null !default;\n\n$input-color: var(--#{$prefix}body-color) !default;\n$input-border-color: var(--#{$prefix}border-color) !default;\n$input-border-width: $input-btn-border-width !default;\n$input-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$input-border-radius: var(--#{$prefix}border-radius) !default;\n$input-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$input-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n\n$input-focus-bg: $input-bg !default;\n$input-focus-border-color: tint-color($component-active-bg, 50%) !default;\n$input-focus-color: $input-color !default;\n$input-focus-width: $input-btn-focus-width !default;\n$input-focus-box-shadow: $input-btn-focus-box-shadow !default;\n\n$input-placeholder-color: var(--#{$prefix}secondary-color) !default;\n$input-plaintext-color: var(--#{$prefix}body-color) !default;\n\n$input-height-border: calc(#{$input-border-width} * 2) !default; // stylelint-disable-line function-disallowed-list\n\n$input-height-inner: add($input-line-height * 1em, $input-padding-y * 2) !default;\n$input-height-inner-half: add($input-line-height * .5em, $input-padding-y) !default;\n$input-height-inner-quarter: add($input-line-height * .25em, $input-padding-y * .5) !default;\n\n$input-height: add($input-line-height * 1em, add($input-padding-y * 2, $input-height-border, false)) !default;\n$input-height-sm: add($input-line-height * 1em, add($input-padding-y-sm * 2, $input-height-border, false)) !default;\n$input-height-lg: add($input-line-height * 1em, add($input-padding-y-lg * 2, $input-height-border, false)) !default;\n\n$input-transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$form-color-width: 3rem !default;\n// scss-docs-end form-input-variables\n\n// scss-docs-start form-check-variables\n$form-check-input-width: 1em !default;\n$form-check-min-height: $font-size-base * $line-height-base !default;\n$form-check-padding-start: $form-check-input-width + .5em !default;\n$form-check-margin-bottom: .125rem !default;\n$form-check-label-color: null !default;\n$form-check-label-cursor: null !default;\n$form-check-transition: null !default;\n\n$form-check-input-active-filter: brightness(90%) !default;\n\n$form-check-input-bg: $input-bg !default;\n$form-check-input-border: var(--#{$prefix}border-width) solid var(--#{$prefix}border-color) !default;\n$form-check-input-border-radius: .25em !default;\n$form-check-radio-border-radius: 50% !default;\n$form-check-input-focus-border: $input-focus-border-color !default;\n$form-check-input-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$form-check-input-checked-color: $component-active-color !default;\n$form-check-input-checked-bg-color: $component-active-bg !default;\n$form-check-input-checked-border-color: $form-check-input-checked-bg-color !default;\n$form-check-input-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-check-radio-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-check-input-indeterminate-color: $component-active-color !default;\n$form-check-input-indeterminate-bg-color: $component-active-bg !default;\n$form-check-input-indeterminate-border-color: $form-check-input-indeterminate-bg-color !default;\n$form-check-input-indeterminate-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-check-input-disabled-opacity: .5 !default;\n$form-check-label-disabled-opacity: $form-check-input-disabled-opacity !default;\n$form-check-btn-check-disabled-opacity: $btn-disabled-opacity !default;\n\n$form-check-inline-margin-end: 1rem !default;\n// scss-docs-end form-check-variables\n\n// scss-docs-start form-switch-variables\n$form-switch-color: rgba($black, .25) !default;\n$form-switch-width: 2em !default;\n$form-switch-padding-start: $form-switch-width + .5em !default;\n$form-switch-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-switch-border-radius: $form-switch-width !default;\n$form-switch-transition: background-position .15s ease-in-out !default;\n\n$form-switch-focus-color: $input-focus-border-color !default;\n$form-switch-focus-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-switch-checked-color: $component-active-color !default;\n$form-switch-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-switch-checked-bg-position: right center !default;\n// scss-docs-end form-switch-variables\n\n// scss-docs-start input-group-variables\n$input-group-addon-padding-y: $input-padding-y !default;\n$input-group-addon-padding-x: $input-padding-x !default;\n$input-group-addon-font-weight: $input-font-weight !default;\n$input-group-addon-color: $input-color !default;\n$input-group-addon-bg: var(--#{$prefix}tertiary-bg) !default;\n$input-group-addon-border-color: $input-border-color !default;\n// scss-docs-end input-group-variables\n\n// scss-docs-start form-select-variables\n$form-select-padding-y: $input-padding-y !default;\n$form-select-padding-x: $input-padding-x !default;\n$form-select-font-family: $input-font-family !default;\n$form-select-font-size: $input-font-size !default;\n$form-select-indicator-padding: $form-select-padding-x * 3 !default; // Extra padding for background-image\n$form-select-font-weight: $input-font-weight !default;\n$form-select-line-height: $input-line-height !default;\n$form-select-color: $input-color !default;\n$form-select-bg: $input-bg !default;\n$form-select-disabled-color: null !default;\n$form-select-disabled-bg: $input-disabled-bg !default;\n$form-select-disabled-border-color: $input-disabled-border-color !default;\n$form-select-bg-position: right $form-select-padding-x center !default;\n$form-select-bg-size: 16px 12px !default; // In pixels because image dimensions\n$form-select-indicator-color: $gray-800 !default;\n$form-select-indicator: url(\"data:image/svg+xml,\") !default;\n\n$form-select-feedback-icon-padding-end: $form-select-padding-x * 2.5 + $form-select-indicator-padding !default;\n$form-select-feedback-icon-position: center right $form-select-indicator-padding !default;\n$form-select-feedback-icon-size: $input-height-inner-half $input-height-inner-half !default;\n\n$form-select-border-width: $input-border-width !default;\n$form-select-border-color: $input-border-color !default;\n$form-select-border-radius: $input-border-radius !default;\n$form-select-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$form-select-focus-border-color: $input-focus-border-color !default;\n$form-select-focus-width: $input-focus-width !default;\n$form-select-focus-box-shadow: 0 0 0 $form-select-focus-width $input-btn-focus-color !default;\n\n$form-select-padding-y-sm: $input-padding-y-sm !default;\n$form-select-padding-x-sm: $input-padding-x-sm !default;\n$form-select-font-size-sm: $input-font-size-sm !default;\n$form-select-border-radius-sm: $input-border-radius-sm !default;\n\n$form-select-padding-y-lg: $input-padding-y-lg !default;\n$form-select-padding-x-lg: $input-padding-x-lg !default;\n$form-select-font-size-lg: $input-font-size-lg !default;\n$form-select-border-radius-lg: $input-border-radius-lg !default;\n\n$form-select-transition: $input-transition !default;\n// scss-docs-end form-select-variables\n\n// scss-docs-start form-range-variables\n$form-range-track-width: 100% !default;\n$form-range-track-height: .5rem !default;\n$form-range-track-cursor: pointer !default;\n$form-range-track-bg: var(--#{$prefix}secondary-bg) !default;\n$form-range-track-border-radius: 1rem !default;\n$form-range-track-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$form-range-thumb-width: 1rem !default;\n$form-range-thumb-height: $form-range-thumb-width !default;\n$form-range-thumb-bg: $component-active-bg !default;\n$form-range-thumb-border: 0 !default;\n$form-range-thumb-border-radius: 1rem !default;\n$form-range-thumb-box-shadow: 0 .1rem .25rem rgba($black, .1) !default;\n$form-range-thumb-focus-box-shadow: 0 0 0 1px $body-bg, $input-focus-box-shadow !default;\n$form-range-thumb-focus-box-shadow-width: $input-focus-width !default; // For focus box shadow issue in Edge\n$form-range-thumb-active-bg: tint-color($component-active-bg, 70%) !default;\n$form-range-thumb-disabled-bg: var(--#{$prefix}secondary-color) !default;\n$form-range-thumb-transition: background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n// scss-docs-end form-range-variables\n\n// scss-docs-start form-file-variables\n$form-file-button-color: $input-color !default;\n$form-file-button-bg: var(--#{$prefix}tertiary-bg) !default;\n$form-file-button-hover-bg: var(--#{$prefix}secondary-bg) !default;\n// scss-docs-end form-file-variables\n\n// scss-docs-start form-floating-variables\n$form-floating-height: add(3.5rem, $input-height-border) !default;\n$form-floating-line-height: 1.25 !default;\n$form-floating-padding-x: $input-padding-x !default;\n$form-floating-padding-y: 1rem !default;\n$form-floating-input-padding-t: 1.625rem !default;\n$form-floating-input-padding-b: .625rem !default;\n$form-floating-label-height: 1.5em !default;\n$form-floating-label-opacity: .65 !default;\n$form-floating-label-transform: scale(.85) translateY(-.5rem) translateX(.15rem) !default;\n$form-floating-label-disabled-color: $gray-600 !default;\n$form-floating-transition: opacity .1s ease-in-out, transform .1s ease-in-out !default;\n// scss-docs-end form-floating-variables\n\n// Form validation\n\n// scss-docs-start form-feedback-variables\n$form-feedback-margin-top: $form-text-margin-top !default;\n$form-feedback-font-size: $form-text-font-size !default;\n$form-feedback-font-style: $form-text-font-style !default;\n$form-feedback-valid-color: $success !default;\n$form-feedback-invalid-color: $danger !default;\n\n$form-feedback-icon-valid-color: $form-feedback-valid-color !default;\n$form-feedback-icon-valid: url(\"data:image/svg+xml,\") !default;\n$form-feedback-icon-invalid-color: $form-feedback-invalid-color !default;\n$form-feedback-icon-invalid: url(\"data:image/svg+xml,\") !default;\n// scss-docs-end form-feedback-variables\n\n// scss-docs-start form-validation-colors\n$form-valid-color: $form-feedback-valid-color !default;\n$form-valid-border-color: $form-feedback-valid-color !default;\n$form-invalid-color: $form-feedback-invalid-color !default;\n$form-invalid-border-color: $form-feedback-invalid-color !default;\n// scss-docs-end form-validation-colors\n\n// scss-docs-start form-validation-states\n$form-validation-states: (\n \"valid\": (\n \"color\": var(--#{$prefix}form-valid-color),\n \"icon\": $form-feedback-icon-valid,\n \"tooltip-color\": #fff,\n \"tooltip-bg-color\": var(--#{$prefix}success),\n \"focus-box-shadow\": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}success-rgb), $input-btn-focus-color-opacity),\n \"border-color\": var(--#{$prefix}form-valid-border-color),\n ),\n \"invalid\": (\n \"color\": var(--#{$prefix}form-invalid-color),\n \"icon\": $form-feedback-icon-invalid,\n \"tooltip-color\": #fff,\n \"tooltip-bg-color\": var(--#{$prefix}danger),\n \"focus-box-shadow\": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}danger-rgb), $input-btn-focus-color-opacity),\n \"border-color\": var(--#{$prefix}form-invalid-border-color),\n )\n) !default;\n// scss-docs-end form-validation-states\n\n// Z-index master list\n//\n// Warning: Avoid customizing these values. They're used for a bird's eye view\n// of components dependent on the z-axis and are designed to all work together.\n\n// scss-docs-start zindex-stack\n$zindex-dropdown: 1000 !default;\n$zindex-sticky: 1020 !default;\n$zindex-fixed: 1030 !default;\n$zindex-offcanvas-backdrop: 1040 !default;\n$zindex-offcanvas: 1045 !default;\n$zindex-modal-backdrop: 1050 !default;\n$zindex-modal: 1055 !default;\n$zindex-popover: 1070 !default;\n$zindex-tooltip: 1080 !default;\n$zindex-toast: 1090 !default;\n// scss-docs-end zindex-stack\n\n// scss-docs-start zindex-levels-map\n$zindex-levels: (\n n1: -1,\n 0: 0,\n 1: 1,\n 2: 2,\n 3: 3\n) !default;\n// scss-docs-end zindex-levels-map\n\n\n// Navs\n\n// scss-docs-start nav-variables\n$nav-link-padding-y: .5rem !default;\n$nav-link-padding-x: 1rem !default;\n$nav-link-font-size: null !default;\n$nav-link-font-weight: null !default;\n$nav-link-color: var(--#{$prefix}link-color) !default;\n$nav-link-hover-color: var(--#{$prefix}link-hover-color) !default;\n$nav-link-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out !default;\n$nav-link-disabled-color: var(--#{$prefix}secondary-color) !default;\n$nav-link-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$nav-tabs-border-color: var(--#{$prefix}border-color) !default;\n$nav-tabs-border-width: var(--#{$prefix}border-width) !default;\n$nav-tabs-border-radius: var(--#{$prefix}border-radius) !default;\n$nav-tabs-link-hover-border-color: var(--#{$prefix}secondary-bg) var(--#{$prefix}secondary-bg) $nav-tabs-border-color !default;\n$nav-tabs-link-active-color: var(--#{$prefix}emphasis-color) !default;\n$nav-tabs-link-active-bg: var(--#{$prefix}body-bg) !default;\n$nav-tabs-link-active-border-color: var(--#{$prefix}border-color) var(--#{$prefix}border-color) $nav-tabs-link-active-bg !default;\n\n$nav-pills-border-radius: var(--#{$prefix}border-radius) !default;\n$nav-pills-link-active-color: $component-active-color !default;\n$nav-pills-link-active-bg: $component-active-bg !default;\n\n$nav-underline-gap: 1rem !default;\n$nav-underline-border-width: .125rem !default;\n$nav-underline-link-active-color: var(--#{$prefix}emphasis-color) !default;\n// scss-docs-end nav-variables\n\n\n// Navbar\n\n// scss-docs-start navbar-variables\n$navbar-padding-y: $spacer * .5 !default;\n$navbar-padding-x: null !default;\n\n$navbar-nav-link-padding-x: .5rem !default;\n\n$navbar-brand-font-size: $font-size-lg !default;\n// Compute the navbar-brand padding-y so the navbar-brand will have the same height as navbar-text and nav-link\n$nav-link-height: $font-size-base * $line-height-base + $nav-link-padding-y * 2 !default;\n$navbar-brand-height: $navbar-brand-font-size * $line-height-base !default;\n$navbar-brand-padding-y: ($nav-link-height - $navbar-brand-height) * .5 !default;\n$navbar-brand-margin-end: 1rem !default;\n\n$navbar-toggler-padding-y: .25rem !default;\n$navbar-toggler-padding-x: .75rem !default;\n$navbar-toggler-font-size: $font-size-lg !default;\n$navbar-toggler-border-radius: $btn-border-radius !default;\n$navbar-toggler-focus-width: $btn-focus-width !default;\n$navbar-toggler-transition: box-shadow .15s ease-in-out !default;\n\n$navbar-light-color: rgba(var(--#{$prefix}emphasis-color-rgb), .65) !default;\n$navbar-light-hover-color: rgba(var(--#{$prefix}emphasis-color-rgb), .8) !default;\n$navbar-light-active-color: rgba(var(--#{$prefix}emphasis-color-rgb), 1) !default;\n$navbar-light-disabled-color: rgba(var(--#{$prefix}emphasis-color-rgb), .3) !default;\n$navbar-light-icon-color: rgba($body-color, .75) !default;\n$navbar-light-toggler-icon-bg: url(\"data:image/svg+xml,\") !default;\n$navbar-light-toggler-border-color: rgba(var(--#{$prefix}emphasis-color-rgb), .15) !default;\n$navbar-light-brand-color: $navbar-light-active-color !default;\n$navbar-light-brand-hover-color: $navbar-light-active-color !default;\n// scss-docs-end navbar-variables\n\n// scss-docs-start navbar-dark-variables\n$navbar-dark-color: rgba($white, .55) !default;\n$navbar-dark-hover-color: rgba($white, .75) !default;\n$navbar-dark-active-color: $white !default;\n$navbar-dark-disabled-color: rgba($white, .25) !default;\n$navbar-dark-icon-color: $navbar-dark-color !default;\n$navbar-dark-toggler-icon-bg: url(\"data:image/svg+xml,\") !default;\n$navbar-dark-toggler-border-color: rgba($white, .1) !default;\n$navbar-dark-brand-color: $navbar-dark-active-color !default;\n$navbar-dark-brand-hover-color: $navbar-dark-active-color !default;\n// scss-docs-end navbar-dark-variables\n\n\n// Dropdowns\n//\n// Dropdown menu container and contents.\n\n// scss-docs-start dropdown-variables\n$dropdown-min-width: 10rem !default;\n$dropdown-padding-x: 0 !default;\n$dropdown-padding-y: .5rem !default;\n$dropdown-spacer: .125rem !default;\n$dropdown-font-size: $font-size-base !default;\n$dropdown-color: var(--#{$prefix}body-color) !default;\n$dropdown-bg: var(--#{$prefix}body-bg) !default;\n$dropdown-border-color: var(--#{$prefix}border-color-translucent) !default;\n$dropdown-border-radius: var(--#{$prefix}border-radius) !default;\n$dropdown-border-width: var(--#{$prefix}border-width) !default;\n$dropdown-inner-border-radius: calc(#{$dropdown-border-radius} - #{$dropdown-border-width}) !default; // stylelint-disable-line function-disallowed-list\n$dropdown-divider-bg: $dropdown-border-color !default;\n$dropdown-divider-margin-y: $spacer * .5 !default;\n$dropdown-box-shadow: var(--#{$prefix}box-shadow) !default;\n\n$dropdown-link-color: var(--#{$prefix}body-color) !default;\n$dropdown-link-hover-color: $dropdown-link-color !default;\n$dropdown-link-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n\n$dropdown-link-active-color: $component-active-color !default;\n$dropdown-link-active-bg: $component-active-bg !default;\n\n$dropdown-link-disabled-color: var(--#{$prefix}tertiary-color) !default;\n\n$dropdown-item-padding-y: $spacer * .25 !default;\n$dropdown-item-padding-x: $spacer !default;\n\n$dropdown-header-color: $gray-600 !default;\n$dropdown-header-padding-x: $dropdown-item-padding-x !default;\n$dropdown-header-padding-y: $dropdown-padding-y !default;\n// fusv-disable\n$dropdown-header-padding: $dropdown-header-padding-y $dropdown-header-padding-x !default; // Deprecated in v5.2.0\n// fusv-enable\n// scss-docs-end dropdown-variables\n\n// scss-docs-start dropdown-dark-variables\n$dropdown-dark-color: $gray-300 !default;\n$dropdown-dark-bg: $gray-800 !default;\n$dropdown-dark-border-color: $dropdown-border-color !default;\n$dropdown-dark-divider-bg: $dropdown-divider-bg !default;\n$dropdown-dark-box-shadow: null !default;\n$dropdown-dark-link-color: $dropdown-dark-color !default;\n$dropdown-dark-link-hover-color: $white !default;\n$dropdown-dark-link-hover-bg: rgba($white, .15) !default;\n$dropdown-dark-link-active-color: $dropdown-link-active-color !default;\n$dropdown-dark-link-active-bg: $dropdown-link-active-bg !default;\n$dropdown-dark-link-disabled-color: $gray-500 !default;\n$dropdown-dark-header-color: $gray-500 !default;\n// scss-docs-end dropdown-dark-variables\n\n\n// Pagination\n\n// scss-docs-start pagination-variables\n$pagination-padding-y: .375rem !default;\n$pagination-padding-x: .75rem !default;\n$pagination-padding-y-sm: .25rem !default;\n$pagination-padding-x-sm: .5rem !default;\n$pagination-padding-y-lg: .75rem !default;\n$pagination-padding-x-lg: 1.5rem !default;\n\n$pagination-font-size: $font-size-base !default;\n\n$pagination-color: var(--#{$prefix}link-color) !default;\n$pagination-bg: var(--#{$prefix}body-bg) !default;\n$pagination-border-radius: var(--#{$prefix}border-radius) !default;\n$pagination-border-width: var(--#{$prefix}border-width) !default;\n$pagination-margin-start: calc(#{$pagination-border-width} * -1) !default; // stylelint-disable-line function-disallowed-list\n$pagination-border-color: var(--#{$prefix}border-color) !default;\n\n$pagination-focus-color: var(--#{$prefix}link-hover-color) !default;\n$pagination-focus-bg: var(--#{$prefix}secondary-bg) !default;\n$pagination-focus-box-shadow: $focus-ring-box-shadow !default;\n$pagination-focus-outline: 0 !default;\n\n$pagination-hover-color: var(--#{$prefix}link-hover-color) !default;\n$pagination-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n$pagination-hover-border-color: var(--#{$prefix}border-color) !default; // Todo in v6: remove this?\n\n$pagination-active-color: $component-active-color !default;\n$pagination-active-bg: $component-active-bg !default;\n$pagination-active-border-color: $component-active-bg !default;\n\n$pagination-disabled-color: var(--#{$prefix}secondary-color) !default;\n$pagination-disabled-bg: var(--#{$prefix}secondary-bg) !default;\n$pagination-disabled-border-color: var(--#{$prefix}border-color) !default;\n\n$pagination-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$pagination-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$pagination-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n// scss-docs-end pagination-variables\n\n\n// Placeholders\n\n// scss-docs-start placeholders\n$placeholder-opacity-max: .5 !default;\n$placeholder-opacity-min: .2 !default;\n// scss-docs-end placeholders\n\n// Cards\n\n// scss-docs-start card-variables\n$card-spacer-y: $spacer !default;\n$card-spacer-x: $spacer !default;\n$card-title-spacer-y: $spacer * .5 !default;\n$card-title-color: null !default;\n$card-subtitle-color: null !default;\n$card-border-width: var(--#{$prefix}border-width) !default;\n$card-border-color: var(--#{$prefix}border-color-translucent) !default;\n$card-border-radius: var(--#{$prefix}border-radius) !default;\n$card-box-shadow: null !default;\n$card-inner-border-radius: subtract($card-border-radius, $card-border-width) !default;\n$card-cap-padding-y: $card-spacer-y * .5 !default;\n$card-cap-padding-x: $card-spacer-x !default;\n$card-cap-bg: rgba(var(--#{$prefix}body-color-rgb), .03) !default;\n$card-cap-color: null !default;\n$card-height: null !default;\n$card-color: null !default;\n$card-bg: var(--#{$prefix}body-bg) !default;\n$card-img-overlay-padding: $spacer !default;\n$card-group-margin: $grid-gutter-width * .5 !default;\n// scss-docs-end card-variables\n\n// Accordion\n\n// scss-docs-start accordion-variables\n$accordion-padding-y: 1rem !default;\n$accordion-padding-x: 1.25rem !default;\n$accordion-color: var(--#{$prefix}body-color) !default;\n$accordion-bg: var(--#{$prefix}body-bg) !default;\n$accordion-border-width: var(--#{$prefix}border-width) !default;\n$accordion-border-color: var(--#{$prefix}border-color) !default;\n$accordion-border-radius: var(--#{$prefix}border-radius) !default;\n$accordion-inner-border-radius: subtract($accordion-border-radius, $accordion-border-width) !default;\n\n$accordion-body-padding-y: $accordion-padding-y !default;\n$accordion-body-padding-x: $accordion-padding-x !default;\n\n$accordion-button-padding-y: $accordion-padding-y !default;\n$accordion-button-padding-x: $accordion-padding-x !default;\n$accordion-button-color: var(--#{$prefix}body-color) !default;\n$accordion-button-bg: var(--#{$prefix}accordion-bg) !default;\n$accordion-transition: $btn-transition, border-radius .15s ease !default;\n$accordion-button-active-bg: var(--#{$prefix}primary-bg-subtle) !default;\n$accordion-button-active-color: var(--#{$prefix}primary-text-emphasis) !default;\n\n// fusv-disable\n$accordion-button-focus-border-color: $input-focus-border-color !default; // Deprecated in v5.3.3\n// fusv-enable\n$accordion-button-focus-box-shadow: $btn-focus-box-shadow !default;\n\n$accordion-icon-width: 1.25rem !default;\n$accordion-icon-color: $body-color !default;\n$accordion-icon-active-color: $primary-text-emphasis !default;\n$accordion-icon-transition: transform .2s ease-in-out !default;\n$accordion-icon-transform: rotate(-180deg) !default;\n\n$accordion-button-icon: url(\"data:image/svg+xml,\") !default;\n$accordion-button-active-icon: url(\"data:image/svg+xml,\") !default;\n// scss-docs-end accordion-variables\n\n// Tooltips\n\n// scss-docs-start tooltip-variables\n$tooltip-font-size: $font-size-sm !default;\n$tooltip-max-width: 200px !default;\n$tooltip-color: var(--#{$prefix}body-bg) !default;\n$tooltip-bg: var(--#{$prefix}emphasis-color) !default;\n$tooltip-border-radius: var(--#{$prefix}border-radius) !default;\n$tooltip-opacity: .9 !default;\n$tooltip-padding-y: $spacer * .25 !default;\n$tooltip-padding-x: $spacer * .5 !default;\n$tooltip-margin: null !default; // TODO: remove this in v6\n\n$tooltip-arrow-width: .8rem !default;\n$tooltip-arrow-height: .4rem !default;\n// fusv-disable\n$tooltip-arrow-color: null !default; // Deprecated in Bootstrap 5.2.0 for CSS variables\n// fusv-enable\n// scss-docs-end tooltip-variables\n\n// Form tooltips must come after regular tooltips\n// scss-docs-start tooltip-feedback-variables\n$form-feedback-tooltip-padding-y: $tooltip-padding-y !default;\n$form-feedback-tooltip-padding-x: $tooltip-padding-x !default;\n$form-feedback-tooltip-font-size: $tooltip-font-size !default;\n$form-feedback-tooltip-line-height: null !default;\n$form-feedback-tooltip-opacity: $tooltip-opacity !default;\n$form-feedback-tooltip-border-radius: $tooltip-border-radius !default;\n// scss-docs-end tooltip-feedback-variables\n\n\n// Popovers\n\n// scss-docs-start popover-variables\n$popover-font-size: $font-size-sm !default;\n$popover-bg: var(--#{$prefix}body-bg) !default;\n$popover-max-width: 276px !default;\n$popover-border-width: var(--#{$prefix}border-width) !default;\n$popover-border-color: var(--#{$prefix}border-color-translucent) !default;\n$popover-border-radius: var(--#{$prefix}border-radius-lg) !default;\n$popover-inner-border-radius: calc(#{$popover-border-radius} - #{$popover-border-width}) !default; // stylelint-disable-line function-disallowed-list\n$popover-box-shadow: var(--#{$prefix}box-shadow) !default;\n\n$popover-header-font-size: $font-size-base !default;\n$popover-header-bg: var(--#{$prefix}secondary-bg) !default;\n$popover-header-color: $headings-color !default;\n$popover-header-padding-y: .5rem !default;\n$popover-header-padding-x: $spacer !default;\n\n$popover-body-color: var(--#{$prefix}body-color) !default;\n$popover-body-padding-y: $spacer !default;\n$popover-body-padding-x: $spacer !default;\n\n$popover-arrow-width: 1rem !default;\n$popover-arrow-height: .5rem !default;\n// scss-docs-end popover-variables\n\n// fusv-disable\n// Deprecated in Bootstrap 5.2.0 for CSS variables\n$popover-arrow-color: $popover-bg !default;\n$popover-arrow-outer-color: var(--#{$prefix}border-color-translucent) !default;\n// fusv-enable\n\n\n// Toasts\n\n// scss-docs-start toast-variables\n$toast-max-width: 350px !default;\n$toast-padding-x: .75rem !default;\n$toast-padding-y: .5rem !default;\n$toast-font-size: .875rem !default;\n$toast-color: null !default;\n$toast-background-color: rgba(var(--#{$prefix}body-bg-rgb), .85) !default;\n$toast-border-width: var(--#{$prefix}border-width) !default;\n$toast-border-color: var(--#{$prefix}border-color-translucent) !default;\n$toast-border-radius: var(--#{$prefix}border-radius) !default;\n$toast-box-shadow: var(--#{$prefix}box-shadow) !default;\n$toast-spacing: $container-padding-x !default;\n\n$toast-header-color: var(--#{$prefix}secondary-color) !default;\n$toast-header-background-color: rgba(var(--#{$prefix}body-bg-rgb), .85) !default;\n$toast-header-border-color: $toast-border-color !default;\n// scss-docs-end toast-variables\n\n\n// Badges\n\n// scss-docs-start badge-variables\n$badge-font-size: .75em !default;\n$badge-font-weight: $font-weight-bold !default;\n$badge-color: $white !default;\n$badge-padding-y: .35em !default;\n$badge-padding-x: .65em !default;\n$badge-border-radius: var(--#{$prefix}border-radius) !default;\n// scss-docs-end badge-variables\n\n\n// Modals\n\n// scss-docs-start modal-variables\n$modal-inner-padding: $spacer !default;\n\n$modal-footer-margin-between: .5rem !default;\n\n$modal-dialog-margin: .5rem !default;\n$modal-dialog-margin-y-sm-up: 1.75rem !default;\n\n$modal-title-line-height: $line-height-base !default;\n\n$modal-content-color: null !default;\n$modal-content-bg: var(--#{$prefix}body-bg) !default;\n$modal-content-border-color: var(--#{$prefix}border-color-translucent) !default;\n$modal-content-border-width: var(--#{$prefix}border-width) !default;\n$modal-content-border-radius: var(--#{$prefix}border-radius-lg) !default;\n$modal-content-inner-border-radius: subtract($modal-content-border-radius, $modal-content-border-width) !default;\n$modal-content-box-shadow-xs: var(--#{$prefix}box-shadow-sm) !default;\n$modal-content-box-shadow-sm-up: var(--#{$prefix}box-shadow) !default;\n\n$modal-backdrop-bg: $black !default;\n$modal-backdrop-opacity: .5 !default;\n\n$modal-header-border-color: var(--#{$prefix}border-color) !default;\n$modal-header-border-width: $modal-content-border-width !default;\n$modal-header-padding-y: $modal-inner-padding !default;\n$modal-header-padding-x: $modal-inner-padding !default;\n$modal-header-padding: $modal-header-padding-y $modal-header-padding-x !default; // Keep this for backwards compatibility\n\n$modal-footer-bg: null !default;\n$modal-footer-border-color: $modal-header-border-color !default;\n$modal-footer-border-width: $modal-header-border-width !default;\n\n$modal-sm: 300px !default;\n$modal-md: 500px !default;\n$modal-lg: 800px !default;\n$modal-xl: 1140px !default;\n\n$modal-fade-transform: translate(0, -50px) !default;\n$modal-show-transform: none !default;\n$modal-transition: transform .3s ease-out !default;\n$modal-scale-transform: scale(1.02) !default;\n// scss-docs-end modal-variables\n\n\n// Alerts\n//\n// Define alert colors, border radius, and padding.\n\n// scss-docs-start alert-variables\n$alert-padding-y: $spacer !default;\n$alert-padding-x: $spacer !default;\n$alert-margin-bottom: 1rem !default;\n$alert-border-radius: var(--#{$prefix}border-radius) !default;\n$alert-link-font-weight: $font-weight-bold !default;\n$alert-border-width: var(--#{$prefix}border-width) !default;\n$alert-dismissible-padding-r: $alert-padding-x * 3 !default; // 3x covers width of x plus default padding on either side\n// scss-docs-end alert-variables\n\n// fusv-disable\n$alert-bg-scale: -80% !default; // Deprecated in v5.2.0, to be removed in v6\n$alert-border-scale: -70% !default; // Deprecated in v5.2.0, to be removed in v6\n$alert-color-scale: 40% !default; // Deprecated in v5.2.0, to be removed in v6\n// fusv-enable\n\n// Progress bars\n\n// scss-docs-start progress-variables\n$progress-height: 1rem !default;\n$progress-font-size: $font-size-base * .75 !default;\n$progress-bg: var(--#{$prefix}secondary-bg) !default;\n$progress-border-radius: var(--#{$prefix}border-radius) !default;\n$progress-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n$progress-bar-color: $white !default;\n$progress-bar-bg: $primary !default;\n$progress-bar-animation-timing: 1s linear infinite !default;\n$progress-bar-transition: width .6s ease !default;\n// scss-docs-end progress-variables\n\n\n// List group\n\n// scss-docs-start list-group-variables\n$list-group-color: var(--#{$prefix}body-color) !default;\n$list-group-bg: var(--#{$prefix}body-bg) !default;\n$list-group-border-color: var(--#{$prefix}border-color) !default;\n$list-group-border-width: var(--#{$prefix}border-width) !default;\n$list-group-border-radius: var(--#{$prefix}border-radius) !default;\n\n$list-group-item-padding-y: $spacer * .5 !default;\n$list-group-item-padding-x: $spacer !default;\n// fusv-disable\n$list-group-item-bg-scale: -80% !default; // Deprecated in v5.3.0\n$list-group-item-color-scale: 40% !default; // Deprecated in v5.3.0\n// fusv-enable\n\n$list-group-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n$list-group-active-color: $component-active-color !default;\n$list-group-active-bg: $component-active-bg !default;\n$list-group-active-border-color: $list-group-active-bg !default;\n\n$list-group-disabled-color: var(--#{$prefix}secondary-color) !default;\n$list-group-disabled-bg: $list-group-bg !default;\n\n$list-group-action-color: var(--#{$prefix}secondary-color) !default;\n$list-group-action-hover-color: var(--#{$prefix}emphasis-color) !default;\n\n$list-group-action-active-color: var(--#{$prefix}body-color) !default;\n$list-group-action-active-bg: var(--#{$prefix}secondary-bg) !default;\n// scss-docs-end list-group-variables\n\n\n// Image thumbnails\n\n// scss-docs-start thumbnail-variables\n$thumbnail-padding: .25rem !default;\n$thumbnail-bg: var(--#{$prefix}body-bg) !default;\n$thumbnail-border-width: var(--#{$prefix}border-width) !default;\n$thumbnail-border-color: var(--#{$prefix}border-color) !default;\n$thumbnail-border-radius: var(--#{$prefix}border-radius) !default;\n$thumbnail-box-shadow: var(--#{$prefix}box-shadow-sm) !default;\n// scss-docs-end thumbnail-variables\n\n\n// Figures\n\n// scss-docs-start figure-variables\n$figure-caption-font-size: $small-font-size !default;\n$figure-caption-color: var(--#{$prefix}secondary-color) !default;\n// scss-docs-end figure-variables\n\n\n// Breadcrumbs\n\n// scss-docs-start breadcrumb-variables\n$breadcrumb-font-size: null !default;\n$breadcrumb-padding-y: 0 !default;\n$breadcrumb-padding-x: 0 !default;\n$breadcrumb-item-padding-x: .5rem !default;\n$breadcrumb-margin-bottom: 1rem !default;\n$breadcrumb-bg: null !default;\n$breadcrumb-divider-color: var(--#{$prefix}secondary-color) !default;\n$breadcrumb-active-color: var(--#{$prefix}secondary-color) !default;\n$breadcrumb-divider: quote(\"/\") !default;\n$breadcrumb-divider-flipped: $breadcrumb-divider !default;\n$breadcrumb-border-radius: null !default;\n// scss-docs-end breadcrumb-variables\n\n// Carousel\n\n// scss-docs-start carousel-variables\n$carousel-control-color: $white !default;\n$carousel-control-width: 15% !default;\n$carousel-control-opacity: .5 !default;\n$carousel-control-hover-opacity: .9 !default;\n$carousel-control-transition: opacity .15s ease !default;\n\n$carousel-indicator-width: 30px !default;\n$carousel-indicator-height: 3px !default;\n$carousel-indicator-hit-area-height: 10px !default;\n$carousel-indicator-spacer: 3px !default;\n$carousel-indicator-opacity: .5 !default;\n$carousel-indicator-active-bg: $white !default;\n$carousel-indicator-active-opacity: 1 !default;\n$carousel-indicator-transition: opacity .6s ease !default;\n\n$carousel-caption-width: 70% !default;\n$carousel-caption-color: $white !default;\n$carousel-caption-padding-y: 1.25rem !default;\n$carousel-caption-spacer: 1.25rem !default;\n\n$carousel-control-icon-width: 2rem !default;\n\n$carousel-control-prev-icon-bg: url(\"data:image/svg+xml,\") !default;\n$carousel-control-next-icon-bg: url(\"data:image/svg+xml,\") !default;\n\n$carousel-transition-duration: .6s !default;\n$carousel-transition: transform $carousel-transition-duration ease-in-out !default; // Define transform transition first if using multiple transitions (e.g., `transform 2s ease, opacity .5s ease-out`)\n// scss-docs-end carousel-variables\n\n// scss-docs-start carousel-dark-variables\n$carousel-dark-indicator-active-bg: $black !default;\n$carousel-dark-caption-color: $black !default;\n$carousel-dark-control-icon-filter: invert(1) grayscale(100) !default;\n// scss-docs-end carousel-dark-variables\n\n\n// Spinners\n\n// scss-docs-start spinner-variables\n$spinner-width: 2rem !default;\n$spinner-height: $spinner-width !default;\n$spinner-vertical-align: -.125em !default;\n$spinner-border-width: .25em !default;\n$spinner-animation-speed: .75s !default;\n\n$spinner-width-sm: 1rem !default;\n$spinner-height-sm: $spinner-width-sm !default;\n$spinner-border-width-sm: .2em !default;\n// scss-docs-end spinner-variables\n\n\n// Close\n\n// scss-docs-start close-variables\n$btn-close-width: 1em !default;\n$btn-close-height: $btn-close-width !default;\n$btn-close-padding-x: .25em !default;\n$btn-close-padding-y: $btn-close-padding-x !default;\n$btn-close-color: $black !default;\n$btn-close-bg: url(\"data:image/svg+xml,\") !default;\n$btn-close-focus-shadow: $focus-ring-box-shadow !default;\n$btn-close-opacity: .5 !default;\n$btn-close-hover-opacity: .75 !default;\n$btn-close-focus-opacity: 1 !default;\n$btn-close-disabled-opacity: .25 !default;\n$btn-close-white-filter: invert(1) grayscale(100%) brightness(200%) !default;\n// scss-docs-end close-variables\n\n\n// Offcanvas\n\n// scss-docs-start offcanvas-variables\n$offcanvas-padding-y: $modal-inner-padding !default;\n$offcanvas-padding-x: $modal-inner-padding !default;\n$offcanvas-horizontal-width: 400px !default;\n$offcanvas-vertical-height: 30vh !default;\n$offcanvas-transition-duration: .3s !default;\n$offcanvas-border-color: $modal-content-border-color !default;\n$offcanvas-border-width: $modal-content-border-width !default;\n$offcanvas-title-line-height: $modal-title-line-height !default;\n$offcanvas-bg-color: var(--#{$prefix}body-bg) !default;\n$offcanvas-color: var(--#{$prefix}body-color) !default;\n$offcanvas-box-shadow: $modal-content-box-shadow-xs !default;\n$offcanvas-backdrop-bg: $modal-backdrop-bg !default;\n$offcanvas-backdrop-opacity: $modal-backdrop-opacity !default;\n// scss-docs-end offcanvas-variables\n\n// Code\n\n$code-font-size: $small-font-size !default;\n$code-color: $pink !default;\n\n$kbd-padding-y: .1875rem !default;\n$kbd-padding-x: .375rem !default;\n$kbd-font-size: $code-font-size !default;\n$kbd-color: var(--#{$prefix}body-bg) !default;\n$kbd-bg: var(--#{$prefix}body-color) !default;\n$nested-kbd-font-weight: null !default; // Deprecated in v5.2.0, removing in v6\n\n$pre-color: null !default;\n\n@import \"variables-dark\"; // TODO: can be removed safely in v6, only here to avoid breaking changes in v5.3\n","// Row\n//\n// Rows contain your columns.\n\n:root {\n @each $name, $value in $grid-breakpoints {\n --#{$prefix}breakpoint-#{$name}: #{$value};\n }\n}\n\n@if $enable-grid-classes {\n .row {\n @include make-row();\n\n > * {\n @include make-col-ready();\n }\n }\n}\n\n@if $enable-cssgrid {\n .grid {\n display: grid;\n grid-template-rows: repeat(var(--#{$prefix}rows, 1), 1fr);\n grid-template-columns: repeat(var(--#{$prefix}columns, #{$grid-columns}), 1fr);\n gap: var(--#{$prefix}gap, #{$grid-gutter-width});\n\n @include make-cssgrid();\n }\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n@if $enable-grid-classes {\n @include make-grid-columns();\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n@mixin make-row($gutter: $grid-gutter-width) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n // TODO: Revisit calc order after https://github.com/react-bootstrap/react-bootstrap/issues/6039 is fixed\n margin-top: calc(-1 * var(--#{$prefix}gutter-y)); // stylelint-disable-line function-disallowed-list\n margin-right: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n margin-left: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n}\n\n@mixin make-col-ready() {\n // Add box sizing if only the grid is loaded\n box-sizing: if(variable-exists(include-column-box-sizing) and $include-column-box-sizing, border-box, null);\n // Prevent columns from becoming too narrow when at smaller grid tiers by\n // always setting `width: 100%;`. This works because we set the width\n // later on to override this initial width.\n flex-shrink: 0;\n width: 100%;\n max-width: 100%; // Prevent `.col-auto`, `.col` (& responsive variants) from breaking out the grid\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-top: var(--#{$prefix}gutter-y);\n}\n\n@mixin make-col($size: false, $columns: $grid-columns) {\n @if $size {\n flex: 0 0 auto;\n width: percentage(divide($size, $columns));\n\n } @else {\n flex: 1 1 0;\n max-width: 100%;\n }\n}\n\n@mixin make-col-auto() {\n flex: 0 0 auto;\n width: auto;\n}\n\n@mixin make-col-offset($size, $columns: $grid-columns) {\n $num: divide($size, $columns);\n margin-left: if($num == 0, 0, percentage($num));\n}\n\n// Row columns\n//\n// Specify on a parent element(e.g., .row) to force immediate children into NN\n// number of columns. Supports wrapping to new lines, but does not do a Masonry\n// style grid.\n@mixin row-cols($count) {\n > * {\n flex: 0 0 auto;\n width: percentage(divide(1, $count));\n }\n}\n\n// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `$grid-columns`.\n\n@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n // Provide basic `.col-{bp}` classes for equal-width flexbox columns\n .col#{$infix} {\n flex: 1 0 0%; // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4\n }\n\n .row-cols#{$infix}-auto > * {\n @include make-col-auto();\n }\n\n @if $grid-row-columns > 0 {\n @for $i from 1 through $grid-row-columns {\n .row-cols#{$infix}-#{$i} {\n @include row-cols($i);\n }\n }\n }\n\n .col#{$infix}-auto {\n @include make-col-auto();\n }\n\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @include make-col($i, $columns);\n }\n }\n\n // `$columns - 1` because offsetting by the width of an entire row isn't possible\n @for $i from 0 through ($columns - 1) {\n @if not ($infix == \"\" and $i == 0) { // Avoid emitting useless .offset-0\n .offset#{$infix}-#{$i} {\n @include make-col-offset($i, $columns);\n }\n }\n }\n }\n\n // Gutters\n //\n // Make use of `.g-*`, `.gx-*` or `.gy-*` utilities to change spacing between the columns.\n @each $key, $value in $gutters {\n .g#{$infix}-#{$key},\n .gx#{$infix}-#{$key} {\n --#{$prefix}gutter-x: #{$value};\n }\n\n .g#{$infix}-#{$key},\n .gy#{$infix}-#{$key} {\n --#{$prefix}gutter-y: #{$value};\n }\n }\n }\n }\n}\n\n@mixin make-cssgrid($columns: $grid-columns, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .g-col#{$infix}-#{$i} {\n grid-column: auto / span $i;\n }\n }\n\n // Start with `1` because `0` is an invalid value.\n // Ends with `$columns - 1` because offsetting by the width of an entire row isn't possible.\n @for $i from 1 through ($columns - 1) {\n .g-start#{$infix}-#{$i} {\n grid-column-start: $i;\n }\n }\n }\n }\n }\n}\n","// Utility generator\n// Used to generate utilities & print utilities\n@mixin generate-utility($utility, $infix: \"\", $is-rfs-media-query: false) {\n $values: map-get($utility, values);\n\n // If the values are a list or string, convert it into a map\n @if type-of($values) == \"string\" or type-of(nth($values, 1)) != \"list\" {\n $values: zip($values, $values);\n }\n\n @each $key, $value in $values {\n $properties: map-get($utility, property);\n\n // Multiple properties are possible, for example with vertical or horizontal margins or paddings\n @if type-of($properties) == \"string\" {\n $properties: append((), $properties);\n }\n\n // Use custom class if present\n $property-class: if(map-has-key($utility, class), map-get($utility, class), nth($properties, 1));\n $property-class: if($property-class == null, \"\", $property-class);\n\n // Use custom CSS variable name if present, otherwise default to `class`\n $css-variable-name: if(map-has-key($utility, css-variable-name), map-get($utility, css-variable-name), map-get($utility, class));\n\n // State params to generate pseudo-classes\n $state: if(map-has-key($utility, state), map-get($utility, state), ());\n\n $infix: if($property-class == \"\" and str-slice($infix, 1, 1) == \"-\", str-slice($infix, 2), $infix);\n\n // Don't prefix if value key is null (e.g. with shadow class)\n $property-class-modifier: if($key, if($property-class == \"\" and $infix == \"\", \"\", \"-\") + $key, \"\");\n\n @if map-get($utility, rfs) {\n // Inside the media query\n @if $is-rfs-media-query {\n $val: rfs-value($value);\n\n // Do not render anything if fluid and non fluid values are the same\n $value: if($val == rfs-fluid-value($value), null, $val);\n }\n @else {\n $value: rfs-fluid-value($value);\n }\n }\n\n $is-css-var: map-get($utility, css-var);\n $is-local-vars: map-get($utility, local-vars);\n $is-rtl: map-get($utility, rtl);\n\n @if $value != null {\n @if $is-rtl == false {\n /* rtl:begin:remove */\n }\n\n @if $is-css-var {\n .#{$property-class + $infix + $property-class-modifier} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n }\n } @else {\n .#{$property-class + $infix + $property-class-modifier} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n }\n }\n\n @if $is-rtl == false {\n /* rtl:end:remove */\n }\n }\n }\n}\n","// Loop over each breakpoint\n@each $breakpoint in map-keys($grid-breakpoints) {\n\n // Generate media query if needed\n @include media-breakpoint-up($breakpoint) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix);\n }\n }\n }\n}\n\n// RFS rescaling\n@media (min-width: $rfs-mq-value) {\n @each $breakpoint in map-keys($grid-breakpoints) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n @if (map-get($grid-breakpoints, $breakpoint) < $rfs-breakpoint) {\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and map-get($utility, rfs) and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix, true);\n }\n }\n }\n }\n}\n\n\n// Print utilities\n@media print {\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Then check if the utility needs print styles\n @if type-of($utility) == \"map\" and map-get($utility, print) == true {\n @include generate-utility($utility, \"-print\");\n }\n }\n}\n"]} \ No newline at end of file diff --git a/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css new file mode 100644 index 0000000..6336fc6 --- /dev/null +++ b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap Grid v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{box-sizing:border-box;flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}@media (min-width:576px){.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}}@media (min-width:768px){.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}}@media (min-width:992px){.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}}@media (min-width:1200px){.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}}@media (min-width:1400px){.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap-grid.min.css.map */ \ No newline at end of file diff --git a/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map new file mode 100644 index 0000000..a0db8b5 --- /dev/null +++ b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_containers.scss","dist/css/bootstrap-grid.css","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"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","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n","// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n@if $enable-container-classes {\n // Single container class with breakpoint max-widths\n .container,\n // 100% wide container at all breakpoints\n .container-fluid {\n @include make-container();\n }\n\n // Responsive containers that are 100% wide until a breakpoint\n @each $breakpoint, $container-max-width in $container-max-widths {\n .container-#{$breakpoint} {\n @extend .container-fluid;\n }\n\n @include media-breakpoint-up($breakpoint, $grid-breakpoints) {\n %responsive-container-#{$breakpoint} {\n max-width: $container-max-width;\n }\n\n // Extend each breakpoint which is smaller or equal to the current breakpoint\n $extend-breakpoint: true;\n\n @each $name, $width in $grid-breakpoints {\n @if ($extend-breakpoint) {\n .container#{breakpoint-infix($name, $grid-breakpoints)} {\n @extend %responsive-container-#{$breakpoint};\n }\n\n // Once the current breakpoint is reached, stop extending\n @if ($breakpoint == $name) {\n $extend-breakpoint: false;\n }\n }\n }\n }\n }\n}\n","/*!\n * Bootstrap Grid v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n.container,\n.container-fluid,\n.container-xxl,\n.container-xl,\n.container-lg,\n.container-md,\n.container-sm {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-right: auto;\n margin-left: auto;\n}\n\n@media (min-width: 576px) {\n .container-sm, .container {\n max-width: 540px;\n }\n}\n@media (min-width: 768px) {\n .container-md, .container-sm, .container {\n max-width: 720px;\n }\n}\n@media (min-width: 992px) {\n .container-lg, .container-md, .container-sm, .container {\n max-width: 960px;\n }\n}\n@media (min-width: 1200px) {\n .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1140px;\n }\n}\n@media (min-width: 1400px) {\n .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1320px;\n }\n}\n:root {\n --bs-breakpoint-xs: 0;\n --bs-breakpoint-sm: 576px;\n --bs-breakpoint-md: 768px;\n --bs-breakpoint-lg: 992px;\n --bs-breakpoint-xl: 1200px;\n --bs-breakpoint-xxl: 1400px;\n}\n\n.row {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n margin-top: calc(-1 * var(--bs-gutter-y));\n margin-right: calc(-0.5 * var(--bs-gutter-x));\n margin-left: calc(-0.5 * var(--bs-gutter-x));\n}\n.row > * {\n box-sizing: border-box;\n flex-shrink: 0;\n width: 100%;\n max-width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-top: var(--bs-gutter-y);\n}\n\n.col {\n flex: 1 0 0%;\n}\n\n.row-cols-auto > * {\n flex: 0 0 auto;\n width: auto;\n}\n\n.row-cols-1 > * {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.row-cols-2 > * {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.row-cols-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.row-cols-4 > * {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.row-cols-5 > * {\n flex: 0 0 auto;\n width: 20%;\n}\n\n.row-cols-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-auto {\n flex: 0 0 auto;\n width: auto;\n}\n\n.col-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n}\n\n.col-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-3 {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.col-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.col-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n}\n\n.col-6 {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.col-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n}\n\n.col-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n}\n\n.col-9 {\n flex: 0 0 auto;\n width: 75%;\n}\n\n.col-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n}\n\n.col-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n}\n\n.col-12 {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.offset-1 {\n margin-left: 8.33333333%;\n}\n\n.offset-2 {\n margin-left: 16.66666667%;\n}\n\n.offset-3 {\n margin-left: 25%;\n}\n\n.offset-4 {\n margin-left: 33.33333333%;\n}\n\n.offset-5 {\n margin-left: 41.66666667%;\n}\n\n.offset-6 {\n margin-left: 50%;\n}\n\n.offset-7 {\n margin-left: 58.33333333%;\n}\n\n.offset-8 {\n margin-left: 66.66666667%;\n}\n\n.offset-9 {\n margin-left: 75%;\n}\n\n.offset-10 {\n margin-left: 83.33333333%;\n}\n\n.offset-11 {\n margin-left: 91.66666667%;\n}\n\n.g-0,\n.gx-0 {\n --bs-gutter-x: 0;\n}\n\n.g-0,\n.gy-0 {\n --bs-gutter-y: 0;\n}\n\n.g-1,\n.gx-1 {\n --bs-gutter-x: 0.25rem;\n}\n\n.g-1,\n.gy-1 {\n --bs-gutter-y: 0.25rem;\n}\n\n.g-2,\n.gx-2 {\n --bs-gutter-x: 0.5rem;\n}\n\n.g-2,\n.gy-2 {\n --bs-gutter-y: 0.5rem;\n}\n\n.g-3,\n.gx-3 {\n --bs-gutter-x: 1rem;\n}\n\n.g-3,\n.gy-3 {\n --bs-gutter-y: 1rem;\n}\n\n.g-4,\n.gx-4 {\n --bs-gutter-x: 1.5rem;\n}\n\n.g-4,\n.gy-4 {\n --bs-gutter-y: 1.5rem;\n}\n\n.g-5,\n.gx-5 {\n --bs-gutter-x: 3rem;\n}\n\n.g-5,\n.gy-5 {\n --bs-gutter-y: 3rem;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n flex: 1 0 0%;\n }\n .row-cols-sm-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-sm-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-sm-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-sm-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-sm-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-sm-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-sm-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-sm-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-sm-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-sm-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-sm-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-sm-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-sm-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-sm-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-sm-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-sm-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-sm-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-sm-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-sm-0 {\n margin-left: 0;\n }\n .offset-sm-1 {\n margin-left: 8.33333333%;\n }\n .offset-sm-2 {\n margin-left: 16.66666667%;\n }\n .offset-sm-3 {\n margin-left: 25%;\n }\n .offset-sm-4 {\n margin-left: 33.33333333%;\n }\n .offset-sm-5 {\n margin-left: 41.66666667%;\n }\n .offset-sm-6 {\n margin-left: 50%;\n }\n .offset-sm-7 {\n margin-left: 58.33333333%;\n }\n .offset-sm-8 {\n margin-left: 66.66666667%;\n }\n .offset-sm-9 {\n margin-left: 75%;\n }\n .offset-sm-10 {\n margin-left: 83.33333333%;\n }\n .offset-sm-11 {\n margin-left: 91.66666667%;\n }\n .g-sm-0,\n .gx-sm-0 {\n --bs-gutter-x: 0;\n }\n .g-sm-0,\n .gy-sm-0 {\n --bs-gutter-y: 0;\n }\n .g-sm-1,\n .gx-sm-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-sm-1,\n .gy-sm-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-sm-2,\n .gx-sm-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-sm-2,\n .gy-sm-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-sm-3,\n .gx-sm-3 {\n --bs-gutter-x: 1rem;\n }\n .g-sm-3,\n .gy-sm-3 {\n --bs-gutter-y: 1rem;\n }\n .g-sm-4,\n .gx-sm-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-sm-4,\n .gy-sm-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-sm-5,\n .gx-sm-5 {\n --bs-gutter-x: 3rem;\n }\n .g-sm-5,\n .gy-sm-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 768px) {\n .col-md {\n flex: 1 0 0%;\n }\n .row-cols-md-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-md-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-md-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-md-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-md-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-md-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-md-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-md-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-md-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-md-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-md-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-md-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-md-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-md-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-md-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-md-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-md-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-md-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-md-0 {\n margin-left: 0;\n }\n .offset-md-1 {\n margin-left: 8.33333333%;\n }\n .offset-md-2 {\n margin-left: 16.66666667%;\n }\n .offset-md-3 {\n margin-left: 25%;\n }\n .offset-md-4 {\n margin-left: 33.33333333%;\n }\n .offset-md-5 {\n margin-left: 41.66666667%;\n }\n .offset-md-6 {\n margin-left: 50%;\n }\n .offset-md-7 {\n margin-left: 58.33333333%;\n }\n .offset-md-8 {\n margin-left: 66.66666667%;\n }\n .offset-md-9 {\n margin-left: 75%;\n }\n .offset-md-10 {\n margin-left: 83.33333333%;\n }\n .offset-md-11 {\n margin-left: 91.66666667%;\n }\n .g-md-0,\n .gx-md-0 {\n --bs-gutter-x: 0;\n }\n .g-md-0,\n .gy-md-0 {\n --bs-gutter-y: 0;\n }\n .g-md-1,\n .gx-md-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-md-1,\n .gy-md-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-md-2,\n .gx-md-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-md-2,\n .gy-md-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-md-3,\n .gx-md-3 {\n --bs-gutter-x: 1rem;\n }\n .g-md-3,\n .gy-md-3 {\n --bs-gutter-y: 1rem;\n }\n .g-md-4,\n .gx-md-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-md-4,\n .gy-md-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-md-5,\n .gx-md-5 {\n --bs-gutter-x: 3rem;\n }\n .g-md-5,\n .gy-md-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 992px) {\n .col-lg {\n flex: 1 0 0%;\n }\n .row-cols-lg-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-lg-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-lg-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-lg-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-lg-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-lg-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-lg-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-lg-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-lg-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-lg-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-lg-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-lg-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-lg-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-lg-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-lg-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-lg-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-lg-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-lg-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-lg-0 {\n margin-left: 0;\n }\n .offset-lg-1 {\n margin-left: 8.33333333%;\n }\n .offset-lg-2 {\n margin-left: 16.66666667%;\n }\n .offset-lg-3 {\n margin-left: 25%;\n }\n .offset-lg-4 {\n margin-left: 33.33333333%;\n }\n .offset-lg-5 {\n margin-left: 41.66666667%;\n }\n .offset-lg-6 {\n margin-left: 50%;\n }\n .offset-lg-7 {\n margin-left: 58.33333333%;\n }\n .offset-lg-8 {\n margin-left: 66.66666667%;\n }\n .offset-lg-9 {\n margin-left: 75%;\n }\n .offset-lg-10 {\n margin-left: 83.33333333%;\n }\n .offset-lg-11 {\n margin-left: 91.66666667%;\n }\n .g-lg-0,\n .gx-lg-0 {\n --bs-gutter-x: 0;\n }\n .g-lg-0,\n .gy-lg-0 {\n --bs-gutter-y: 0;\n }\n .g-lg-1,\n .gx-lg-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-lg-1,\n .gy-lg-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-lg-2,\n .gx-lg-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-lg-2,\n .gy-lg-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-lg-3,\n .gx-lg-3 {\n --bs-gutter-x: 1rem;\n }\n .g-lg-3,\n .gy-lg-3 {\n --bs-gutter-y: 1rem;\n }\n .g-lg-4,\n .gx-lg-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-lg-4,\n .gy-lg-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-lg-5,\n .gx-lg-5 {\n --bs-gutter-x: 3rem;\n }\n .g-lg-5,\n .gy-lg-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1200px) {\n .col-xl {\n flex: 1 0 0%;\n }\n .row-cols-xl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xl-0 {\n margin-left: 0;\n }\n .offset-xl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xl-3 {\n margin-left: 25%;\n }\n .offset-xl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xl-6 {\n margin-left: 50%;\n }\n .offset-xl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xl-9 {\n margin-left: 75%;\n }\n .offset-xl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xl-11 {\n margin-left: 91.66666667%;\n }\n .g-xl-0,\n .gx-xl-0 {\n --bs-gutter-x: 0;\n }\n .g-xl-0,\n .gy-xl-0 {\n --bs-gutter-y: 0;\n }\n .g-xl-1,\n .gx-xl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xl-1,\n .gy-xl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xl-2,\n .gx-xl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xl-2,\n .gy-xl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xl-3,\n .gx-xl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xl-3,\n .gy-xl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xl-4,\n .gx-xl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xl-4,\n .gy-xl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xl-5,\n .gx-xl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xl-5,\n .gy-xl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1400px) {\n .col-xxl {\n flex: 1 0 0%;\n }\n .row-cols-xxl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xxl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xxl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xxl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xxl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xxl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xxl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xxl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xxl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xxl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xxl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xxl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xxl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xxl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xxl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xxl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xxl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xxl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xxl-0 {\n margin-left: 0;\n }\n .offset-xxl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xxl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xxl-3 {\n margin-left: 25%;\n }\n .offset-xxl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xxl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xxl-6 {\n margin-left: 50%;\n }\n .offset-xxl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xxl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xxl-9 {\n margin-left: 75%;\n }\n .offset-xxl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xxl-11 {\n margin-left: 91.66666667%;\n }\n .g-xxl-0,\n .gx-xxl-0 {\n --bs-gutter-x: 0;\n }\n .g-xxl-0,\n .gy-xxl-0 {\n --bs-gutter-y: 0;\n }\n .g-xxl-1,\n .gx-xxl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xxl-1,\n .gy-xxl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xxl-2,\n .gx-xxl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xxl-2,\n .gy-xxl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xxl-3,\n .gx-xxl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xxl-3,\n .gy-xxl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xxl-4,\n .gx-xxl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xxl-4,\n .gy-xxl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xxl-5,\n .gx-xxl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xxl-5,\n .gy-xxl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-grid {\n display: grid !important;\n}\n\n.d-inline-grid {\n display: inline-grid !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: flex !important;\n}\n\n.d-inline-flex {\n display: inline-flex !important;\n}\n\n.d-none {\n display: none !important;\n}\n\n.flex-fill {\n flex: 1 1 auto !important;\n}\n\n.flex-row {\n flex-direction: row !important;\n}\n\n.flex-column {\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n flex-direction: column-reverse !important;\n}\n\n.flex-grow-0 {\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n flex-shrink: 1 !important;\n}\n\n.flex-wrap {\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n}\n\n.justify-content-start {\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n justify-content: center !important;\n}\n\n.justify-content-between {\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n justify-content: space-around !important;\n}\n\n.justify-content-evenly {\n justify-content: space-evenly !important;\n}\n\n.align-items-start {\n align-items: flex-start !important;\n}\n\n.align-items-end {\n align-items: flex-end !important;\n}\n\n.align-items-center {\n align-items: center !important;\n}\n\n.align-items-baseline {\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n align-items: stretch !important;\n}\n\n.align-content-start {\n align-content: flex-start !important;\n}\n\n.align-content-end {\n align-content: flex-end !important;\n}\n\n.align-content-center {\n align-content: center !important;\n}\n\n.align-content-between {\n align-content: space-between !important;\n}\n\n.align-content-around {\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n align-content: stretch !important;\n}\n\n.align-self-auto {\n align-self: auto !important;\n}\n\n.align-self-start {\n align-self: flex-start !important;\n}\n\n.align-self-end {\n align-self: flex-end !important;\n}\n\n.align-self-center {\n align-self: center !important;\n}\n\n.align-self-baseline {\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n align-self: stretch !important;\n}\n\n.order-first {\n order: -1 !important;\n}\n\n.order-0 {\n order: 0 !important;\n}\n\n.order-1 {\n order: 1 !important;\n}\n\n.order-2 {\n order: 2 !important;\n}\n\n.order-3 {\n order: 3 !important;\n}\n\n.order-4 {\n order: 4 !important;\n}\n\n.order-5 {\n order: 5 !important;\n}\n\n.order-last {\n order: 6 !important;\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mx-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n}\n\n.mx-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n}\n\n.mx-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n}\n\n.mx-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n}\n\n.mx-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n}\n\n.mx-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n}\n\n.mx-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n}\n\n.my-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n}\n\n.my-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n}\n\n.my-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n}\n\n.my-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n}\n\n.my-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n}\n\n.my-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n}\n\n.my-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n}\n\n.mt-0 {\n margin-top: 0 !important;\n}\n\n.mt-1 {\n margin-top: 0.25rem !important;\n}\n\n.mt-2 {\n margin-top: 0.5rem !important;\n}\n\n.mt-3 {\n margin-top: 1rem !important;\n}\n\n.mt-4 {\n margin-top: 1.5rem !important;\n}\n\n.mt-5 {\n margin-top: 3rem !important;\n}\n\n.mt-auto {\n margin-top: auto !important;\n}\n\n.me-0 {\n margin-right: 0 !important;\n}\n\n.me-1 {\n margin-right: 0.25rem !important;\n}\n\n.me-2 {\n margin-right: 0.5rem !important;\n}\n\n.me-3 {\n margin-right: 1rem !important;\n}\n\n.me-4 {\n margin-right: 1.5rem !important;\n}\n\n.me-5 {\n margin-right: 3rem !important;\n}\n\n.me-auto {\n margin-right: auto !important;\n}\n\n.mb-0 {\n margin-bottom: 0 !important;\n}\n\n.mb-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.mb-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.mb-3 {\n margin-bottom: 1rem !important;\n}\n\n.mb-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.mb-5 {\n margin-bottom: 3rem !important;\n}\n\n.mb-auto {\n margin-bottom: auto !important;\n}\n\n.ms-0 {\n margin-left: 0 !important;\n}\n\n.ms-1 {\n margin-left: 0.25rem !important;\n}\n\n.ms-2 {\n margin-left: 0.5rem !important;\n}\n\n.ms-3 {\n margin-left: 1rem !important;\n}\n\n.ms-4 {\n margin-left: 1.5rem !important;\n}\n\n.ms-5 {\n margin-left: 3rem !important;\n}\n\n.ms-auto {\n margin-left: auto !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.px-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n}\n\n.px-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n}\n\n.px-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n}\n\n.px-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n}\n\n.px-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n}\n\n.px-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n}\n\n.py-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n}\n\n.py-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n}\n\n.py-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n}\n\n.py-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n}\n\n.py-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n}\n\n.py-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n}\n\n.pt-0 {\n padding-top: 0 !important;\n}\n\n.pt-1 {\n padding-top: 0.25rem !important;\n}\n\n.pt-2 {\n padding-top: 0.5rem !important;\n}\n\n.pt-3 {\n padding-top: 1rem !important;\n}\n\n.pt-4 {\n padding-top: 1.5rem !important;\n}\n\n.pt-5 {\n padding-top: 3rem !important;\n}\n\n.pe-0 {\n padding-right: 0 !important;\n}\n\n.pe-1 {\n padding-right: 0.25rem !important;\n}\n\n.pe-2 {\n padding-right: 0.5rem !important;\n}\n\n.pe-3 {\n padding-right: 1rem !important;\n}\n\n.pe-4 {\n padding-right: 1.5rem !important;\n}\n\n.pe-5 {\n padding-right: 3rem !important;\n}\n\n.pb-0 {\n padding-bottom: 0 !important;\n}\n\n.pb-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pb-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pb-3 {\n padding-bottom: 1rem !important;\n}\n\n.pb-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pb-5 {\n padding-bottom: 3rem !important;\n}\n\n.ps-0 {\n padding-left: 0 !important;\n}\n\n.ps-1 {\n padding-left: 0.25rem !important;\n}\n\n.ps-2 {\n padding-left: 0.5rem !important;\n}\n\n.ps-3 {\n padding-left: 1rem !important;\n}\n\n.ps-4 {\n padding-left: 1.5rem !important;\n}\n\n.ps-5 {\n padding-left: 3rem !important;\n}\n\n@media (min-width: 576px) {\n .d-sm-inline {\n display: inline !important;\n }\n .d-sm-inline-block {\n display: inline-block !important;\n }\n .d-sm-block {\n display: block !important;\n }\n .d-sm-grid {\n display: grid !important;\n }\n .d-sm-inline-grid {\n display: inline-grid !important;\n }\n .d-sm-table {\n display: table !important;\n }\n .d-sm-table-row {\n display: table-row !important;\n }\n .d-sm-table-cell {\n display: table-cell !important;\n }\n .d-sm-flex {\n display: flex !important;\n }\n .d-sm-inline-flex {\n display: inline-flex !important;\n }\n .d-sm-none {\n display: none !important;\n }\n .flex-sm-fill {\n flex: 1 1 auto !important;\n }\n .flex-sm-row {\n flex-direction: row !important;\n }\n .flex-sm-column {\n flex-direction: column !important;\n }\n .flex-sm-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-sm-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-sm-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-sm-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-sm-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-sm-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-sm-wrap {\n flex-wrap: wrap !important;\n }\n .flex-sm-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-sm-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-sm-start {\n justify-content: flex-start !important;\n }\n .justify-content-sm-end {\n justify-content: flex-end !important;\n }\n .justify-content-sm-center {\n justify-content: center !important;\n }\n .justify-content-sm-between {\n justify-content: space-between !important;\n }\n .justify-content-sm-around {\n justify-content: space-around !important;\n }\n .justify-content-sm-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-sm-start {\n align-items: flex-start !important;\n }\n .align-items-sm-end {\n align-items: flex-end !important;\n }\n .align-items-sm-center {\n align-items: center !important;\n }\n .align-items-sm-baseline {\n align-items: baseline !important;\n }\n .align-items-sm-stretch {\n align-items: stretch !important;\n }\n .align-content-sm-start {\n align-content: flex-start !important;\n }\n .align-content-sm-end {\n align-content: flex-end !important;\n }\n .align-content-sm-center {\n align-content: center !important;\n }\n .align-content-sm-between {\n align-content: space-between !important;\n }\n .align-content-sm-around {\n align-content: space-around !important;\n }\n .align-content-sm-stretch {\n align-content: stretch !important;\n }\n .align-self-sm-auto {\n align-self: auto !important;\n }\n .align-self-sm-start {\n align-self: flex-start !important;\n }\n .align-self-sm-end {\n align-self: flex-end !important;\n }\n .align-self-sm-center {\n align-self: center !important;\n }\n .align-self-sm-baseline {\n align-self: baseline !important;\n }\n .align-self-sm-stretch {\n align-self: stretch !important;\n }\n .order-sm-first {\n order: -1 !important;\n }\n .order-sm-0 {\n order: 0 !important;\n }\n .order-sm-1 {\n order: 1 !important;\n }\n .order-sm-2 {\n order: 2 !important;\n }\n .order-sm-3 {\n order: 3 !important;\n }\n .order-sm-4 {\n order: 4 !important;\n }\n .order-sm-5 {\n order: 5 !important;\n }\n .order-sm-last {\n order: 6 !important;\n }\n .m-sm-0 {\n margin: 0 !important;\n }\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n .m-sm-3 {\n margin: 1rem !important;\n }\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n .m-sm-5 {\n margin: 3rem !important;\n }\n .m-sm-auto {\n margin: auto !important;\n }\n .mx-sm-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-sm-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-sm-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-sm-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-sm-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-sm-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-sm-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-sm-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-sm-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-sm-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-sm-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-sm-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-sm-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-sm-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-sm-0 {\n margin-top: 0 !important;\n }\n .mt-sm-1 {\n margin-top: 0.25rem !important;\n }\n .mt-sm-2 {\n margin-top: 0.5rem !important;\n }\n .mt-sm-3 {\n margin-top: 1rem !important;\n }\n .mt-sm-4 {\n margin-top: 1.5rem !important;\n }\n .mt-sm-5 {\n margin-top: 3rem !important;\n }\n .mt-sm-auto {\n margin-top: auto !important;\n }\n .me-sm-0 {\n margin-right: 0 !important;\n }\n .me-sm-1 {\n margin-right: 0.25rem !important;\n }\n .me-sm-2 {\n margin-right: 0.5rem !important;\n }\n .me-sm-3 {\n margin-right: 1rem !important;\n }\n .me-sm-4 {\n margin-right: 1.5rem !important;\n }\n .me-sm-5 {\n margin-right: 3rem !important;\n }\n .me-sm-auto {\n margin-right: auto !important;\n }\n .mb-sm-0 {\n margin-bottom: 0 !important;\n }\n .mb-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-sm-3 {\n margin-bottom: 1rem !important;\n }\n .mb-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-sm-5 {\n margin-bottom: 3rem !important;\n }\n .mb-sm-auto {\n margin-bottom: auto !important;\n }\n .ms-sm-0 {\n margin-left: 0 !important;\n }\n .ms-sm-1 {\n margin-left: 0.25rem !important;\n }\n .ms-sm-2 {\n margin-left: 0.5rem !important;\n }\n .ms-sm-3 {\n margin-left: 1rem !important;\n }\n .ms-sm-4 {\n margin-left: 1.5rem !important;\n }\n .ms-sm-5 {\n margin-left: 3rem !important;\n }\n .ms-sm-auto {\n margin-left: auto !important;\n }\n .p-sm-0 {\n padding: 0 !important;\n }\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n .p-sm-3 {\n padding: 1rem !important;\n }\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n .p-sm-5 {\n padding: 3rem !important;\n }\n .px-sm-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-sm-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-sm-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-sm-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-sm-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-sm-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-sm-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-sm-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-sm-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-sm-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-sm-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-sm-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-sm-0 {\n padding-top: 0 !important;\n }\n .pt-sm-1 {\n padding-top: 0.25rem !important;\n }\n .pt-sm-2 {\n padding-top: 0.5rem !important;\n }\n .pt-sm-3 {\n padding-top: 1rem !important;\n }\n .pt-sm-4 {\n padding-top: 1.5rem !important;\n }\n .pt-sm-5 {\n padding-top: 3rem !important;\n }\n .pe-sm-0 {\n padding-right: 0 !important;\n }\n .pe-sm-1 {\n padding-right: 0.25rem !important;\n }\n .pe-sm-2 {\n padding-right: 0.5rem !important;\n }\n .pe-sm-3 {\n padding-right: 1rem !important;\n }\n .pe-sm-4 {\n padding-right: 1.5rem !important;\n }\n .pe-sm-5 {\n padding-right: 3rem !important;\n }\n .pb-sm-0 {\n padding-bottom: 0 !important;\n }\n .pb-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-sm-3 {\n padding-bottom: 1rem !important;\n }\n .pb-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-sm-5 {\n padding-bottom: 3rem !important;\n }\n .ps-sm-0 {\n padding-left: 0 !important;\n }\n .ps-sm-1 {\n padding-left: 0.25rem !important;\n }\n .ps-sm-2 {\n padding-left: 0.5rem !important;\n }\n .ps-sm-3 {\n padding-left: 1rem !important;\n }\n .ps-sm-4 {\n padding-left: 1.5rem !important;\n }\n .ps-sm-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 768px) {\n .d-md-inline {\n display: inline !important;\n }\n .d-md-inline-block {\n display: inline-block !important;\n }\n .d-md-block {\n display: block !important;\n }\n .d-md-grid {\n display: grid !important;\n }\n .d-md-inline-grid {\n display: inline-grid !important;\n }\n .d-md-table {\n display: table !important;\n }\n .d-md-table-row {\n display: table-row !important;\n }\n .d-md-table-cell {\n display: table-cell !important;\n }\n .d-md-flex {\n display: flex !important;\n }\n .d-md-inline-flex {\n display: inline-flex !important;\n }\n .d-md-none {\n display: none !important;\n }\n .flex-md-fill {\n flex: 1 1 auto !important;\n }\n .flex-md-row {\n flex-direction: row !important;\n }\n .flex-md-column {\n flex-direction: column !important;\n }\n .flex-md-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-md-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-md-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-md-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-md-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-md-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-md-wrap {\n flex-wrap: wrap !important;\n }\n .flex-md-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-md-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-md-start {\n justify-content: flex-start !important;\n }\n .justify-content-md-end {\n justify-content: flex-end !important;\n }\n .justify-content-md-center {\n justify-content: center !important;\n }\n .justify-content-md-between {\n justify-content: space-between !important;\n }\n .justify-content-md-around {\n justify-content: space-around !important;\n }\n .justify-content-md-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-md-start {\n align-items: flex-start !important;\n }\n .align-items-md-end {\n align-items: flex-end !important;\n }\n .align-items-md-center {\n align-items: center !important;\n }\n .align-items-md-baseline {\n align-items: baseline !important;\n }\n .align-items-md-stretch {\n align-items: stretch !important;\n }\n .align-content-md-start {\n align-content: flex-start !important;\n }\n .align-content-md-end {\n align-content: flex-end !important;\n }\n .align-content-md-center {\n align-content: center !important;\n }\n .align-content-md-between {\n align-content: space-between !important;\n }\n .align-content-md-around {\n align-content: space-around !important;\n }\n .align-content-md-stretch {\n align-content: stretch !important;\n }\n .align-self-md-auto {\n align-self: auto !important;\n }\n .align-self-md-start {\n align-self: flex-start !important;\n }\n .align-self-md-end {\n align-self: flex-end !important;\n }\n .align-self-md-center {\n align-self: center !important;\n }\n .align-self-md-baseline {\n align-self: baseline !important;\n }\n .align-self-md-stretch {\n align-self: stretch !important;\n }\n .order-md-first {\n order: -1 !important;\n }\n .order-md-0 {\n order: 0 !important;\n }\n .order-md-1 {\n order: 1 !important;\n }\n .order-md-2 {\n order: 2 !important;\n }\n .order-md-3 {\n order: 3 !important;\n }\n .order-md-4 {\n order: 4 !important;\n }\n .order-md-5 {\n order: 5 !important;\n }\n .order-md-last {\n order: 6 !important;\n }\n .m-md-0 {\n margin: 0 !important;\n }\n .m-md-1 {\n margin: 0.25rem !important;\n }\n .m-md-2 {\n margin: 0.5rem !important;\n }\n .m-md-3 {\n margin: 1rem !important;\n }\n .m-md-4 {\n margin: 1.5rem !important;\n }\n .m-md-5 {\n margin: 3rem !important;\n }\n .m-md-auto {\n margin: auto !important;\n }\n .mx-md-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-md-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-md-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-md-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-md-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-md-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-md-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-md-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-md-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-md-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-md-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-md-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-md-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-md-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-md-0 {\n margin-top: 0 !important;\n }\n .mt-md-1 {\n margin-top: 0.25rem !important;\n }\n .mt-md-2 {\n margin-top: 0.5rem !important;\n }\n .mt-md-3 {\n margin-top: 1rem !important;\n }\n .mt-md-4 {\n margin-top: 1.5rem !important;\n }\n .mt-md-5 {\n margin-top: 3rem !important;\n }\n .mt-md-auto {\n margin-top: auto !important;\n }\n .me-md-0 {\n margin-right: 0 !important;\n }\n .me-md-1 {\n margin-right: 0.25rem !important;\n }\n .me-md-2 {\n margin-right: 0.5rem !important;\n }\n .me-md-3 {\n margin-right: 1rem !important;\n }\n .me-md-4 {\n margin-right: 1.5rem !important;\n }\n .me-md-5 {\n margin-right: 3rem !important;\n }\n .me-md-auto {\n margin-right: auto !important;\n }\n .mb-md-0 {\n margin-bottom: 0 !important;\n }\n .mb-md-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-md-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-md-3 {\n margin-bottom: 1rem !important;\n }\n .mb-md-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-md-5 {\n margin-bottom: 3rem !important;\n }\n .mb-md-auto {\n margin-bottom: auto !important;\n }\n .ms-md-0 {\n margin-left: 0 !important;\n }\n .ms-md-1 {\n margin-left: 0.25rem !important;\n }\n .ms-md-2 {\n margin-left: 0.5rem !important;\n }\n .ms-md-3 {\n margin-left: 1rem !important;\n }\n .ms-md-4 {\n margin-left: 1.5rem !important;\n }\n .ms-md-5 {\n margin-left: 3rem !important;\n }\n .ms-md-auto {\n margin-left: auto !important;\n }\n .p-md-0 {\n padding: 0 !important;\n }\n .p-md-1 {\n padding: 0.25rem !important;\n }\n .p-md-2 {\n padding: 0.5rem !important;\n }\n .p-md-3 {\n padding: 1rem !important;\n }\n .p-md-4 {\n padding: 1.5rem !important;\n }\n .p-md-5 {\n padding: 3rem !important;\n }\n .px-md-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-md-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-md-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-md-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-md-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-md-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-md-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-md-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-md-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-md-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-md-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-md-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-md-0 {\n padding-top: 0 !important;\n }\n .pt-md-1 {\n padding-top: 0.25rem !important;\n }\n .pt-md-2 {\n padding-top: 0.5rem !important;\n }\n .pt-md-3 {\n padding-top: 1rem !important;\n }\n .pt-md-4 {\n padding-top: 1.5rem !important;\n }\n .pt-md-5 {\n padding-top: 3rem !important;\n }\n .pe-md-0 {\n padding-right: 0 !important;\n }\n .pe-md-1 {\n padding-right: 0.25rem !important;\n }\n .pe-md-2 {\n padding-right: 0.5rem !important;\n }\n .pe-md-3 {\n padding-right: 1rem !important;\n }\n .pe-md-4 {\n padding-right: 1.5rem !important;\n }\n .pe-md-5 {\n padding-right: 3rem !important;\n }\n .pb-md-0 {\n padding-bottom: 0 !important;\n }\n .pb-md-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-md-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-md-3 {\n padding-bottom: 1rem !important;\n }\n .pb-md-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-md-5 {\n padding-bottom: 3rem !important;\n }\n .ps-md-0 {\n padding-left: 0 !important;\n }\n .ps-md-1 {\n padding-left: 0.25rem !important;\n }\n .ps-md-2 {\n padding-left: 0.5rem !important;\n }\n .ps-md-3 {\n padding-left: 1rem !important;\n }\n .ps-md-4 {\n padding-left: 1.5rem !important;\n }\n .ps-md-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 992px) {\n .d-lg-inline {\n display: inline !important;\n }\n .d-lg-inline-block {\n display: inline-block !important;\n }\n .d-lg-block {\n display: block !important;\n }\n .d-lg-grid {\n display: grid !important;\n }\n .d-lg-inline-grid {\n display: inline-grid !important;\n }\n .d-lg-table {\n display: table !important;\n }\n .d-lg-table-row {\n display: table-row !important;\n }\n .d-lg-table-cell {\n display: table-cell !important;\n }\n .d-lg-flex {\n display: flex !important;\n }\n .d-lg-inline-flex {\n display: inline-flex !important;\n }\n .d-lg-none {\n display: none !important;\n }\n .flex-lg-fill {\n flex: 1 1 auto !important;\n }\n .flex-lg-row {\n flex-direction: row !important;\n }\n .flex-lg-column {\n flex-direction: column !important;\n }\n .flex-lg-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-lg-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-lg-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-lg-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-lg-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-lg-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-lg-wrap {\n flex-wrap: wrap !important;\n }\n .flex-lg-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-lg-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-lg-start {\n justify-content: flex-start !important;\n }\n .justify-content-lg-end {\n justify-content: flex-end !important;\n }\n .justify-content-lg-center {\n justify-content: center !important;\n }\n .justify-content-lg-between {\n justify-content: space-between !important;\n }\n .justify-content-lg-around {\n justify-content: space-around !important;\n }\n .justify-content-lg-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-lg-start {\n align-items: flex-start !important;\n }\n .align-items-lg-end {\n align-items: flex-end !important;\n }\n .align-items-lg-center {\n align-items: center !important;\n }\n .align-items-lg-baseline {\n align-items: baseline !important;\n }\n .align-items-lg-stretch {\n align-items: stretch !important;\n }\n .align-content-lg-start {\n align-content: flex-start !important;\n }\n .align-content-lg-end {\n align-content: flex-end !important;\n }\n .align-content-lg-center {\n align-content: center !important;\n }\n .align-content-lg-between {\n align-content: space-between !important;\n }\n .align-content-lg-around {\n align-content: space-around !important;\n }\n .align-content-lg-stretch {\n align-content: stretch !important;\n }\n .align-self-lg-auto {\n align-self: auto !important;\n }\n .align-self-lg-start {\n align-self: flex-start !important;\n }\n .align-self-lg-end {\n align-self: flex-end !important;\n }\n .align-self-lg-center {\n align-self: center !important;\n }\n .align-self-lg-baseline {\n align-self: baseline !important;\n }\n .align-self-lg-stretch {\n align-self: stretch !important;\n }\n .order-lg-first {\n order: -1 !important;\n }\n .order-lg-0 {\n order: 0 !important;\n }\n .order-lg-1 {\n order: 1 !important;\n }\n .order-lg-2 {\n order: 2 !important;\n }\n .order-lg-3 {\n order: 3 !important;\n }\n .order-lg-4 {\n order: 4 !important;\n }\n .order-lg-5 {\n order: 5 !important;\n }\n .order-lg-last {\n order: 6 !important;\n }\n .m-lg-0 {\n margin: 0 !important;\n }\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n .m-lg-3 {\n margin: 1rem !important;\n }\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n .m-lg-5 {\n margin: 3rem !important;\n }\n .m-lg-auto {\n margin: auto !important;\n }\n .mx-lg-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-lg-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-lg-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-lg-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-lg-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-lg-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-lg-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-lg-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-lg-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-lg-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-lg-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-lg-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-lg-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-lg-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-lg-0 {\n margin-top: 0 !important;\n }\n .mt-lg-1 {\n margin-top: 0.25rem !important;\n }\n .mt-lg-2 {\n margin-top: 0.5rem !important;\n }\n .mt-lg-3 {\n margin-top: 1rem !important;\n }\n .mt-lg-4 {\n margin-top: 1.5rem !important;\n }\n .mt-lg-5 {\n margin-top: 3rem !important;\n }\n .mt-lg-auto {\n margin-top: auto !important;\n }\n .me-lg-0 {\n margin-right: 0 !important;\n }\n .me-lg-1 {\n margin-right: 0.25rem !important;\n }\n .me-lg-2 {\n margin-right: 0.5rem !important;\n }\n .me-lg-3 {\n margin-right: 1rem !important;\n }\n .me-lg-4 {\n margin-right: 1.5rem !important;\n }\n .me-lg-5 {\n margin-right: 3rem !important;\n }\n .me-lg-auto {\n margin-right: auto !important;\n }\n .mb-lg-0 {\n margin-bottom: 0 !important;\n }\n .mb-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-lg-3 {\n margin-bottom: 1rem !important;\n }\n .mb-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-lg-5 {\n margin-bottom: 3rem !important;\n }\n .mb-lg-auto {\n margin-bottom: auto !important;\n }\n .ms-lg-0 {\n margin-left: 0 !important;\n }\n .ms-lg-1 {\n margin-left: 0.25rem !important;\n }\n .ms-lg-2 {\n margin-left: 0.5rem !important;\n }\n .ms-lg-3 {\n margin-left: 1rem !important;\n }\n .ms-lg-4 {\n margin-left: 1.5rem !important;\n }\n .ms-lg-5 {\n margin-left: 3rem !important;\n }\n .ms-lg-auto {\n margin-left: auto !important;\n }\n .p-lg-0 {\n padding: 0 !important;\n }\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n .p-lg-3 {\n padding: 1rem !important;\n }\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n .p-lg-5 {\n padding: 3rem !important;\n }\n .px-lg-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-lg-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-lg-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-lg-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-lg-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-lg-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-lg-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-lg-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-lg-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-lg-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-lg-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-lg-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-lg-0 {\n padding-top: 0 !important;\n }\n .pt-lg-1 {\n padding-top: 0.25rem !important;\n }\n .pt-lg-2 {\n padding-top: 0.5rem !important;\n }\n .pt-lg-3 {\n padding-top: 1rem !important;\n }\n .pt-lg-4 {\n padding-top: 1.5rem !important;\n }\n .pt-lg-5 {\n padding-top: 3rem !important;\n }\n .pe-lg-0 {\n padding-right: 0 !important;\n }\n .pe-lg-1 {\n padding-right: 0.25rem !important;\n }\n .pe-lg-2 {\n padding-right: 0.5rem !important;\n }\n .pe-lg-3 {\n padding-right: 1rem !important;\n }\n .pe-lg-4 {\n padding-right: 1.5rem !important;\n }\n .pe-lg-5 {\n padding-right: 3rem !important;\n }\n .pb-lg-0 {\n padding-bottom: 0 !important;\n }\n .pb-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-lg-3 {\n padding-bottom: 1rem !important;\n }\n .pb-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-lg-5 {\n padding-bottom: 3rem !important;\n }\n .ps-lg-0 {\n padding-left: 0 !important;\n }\n .ps-lg-1 {\n padding-left: 0.25rem !important;\n }\n .ps-lg-2 {\n padding-left: 0.5rem !important;\n }\n .ps-lg-3 {\n padding-left: 1rem !important;\n }\n .ps-lg-4 {\n padding-left: 1.5rem !important;\n }\n .ps-lg-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 1200px) {\n .d-xl-inline {\n display: inline !important;\n }\n .d-xl-inline-block {\n display: inline-block !important;\n }\n .d-xl-block {\n display: block !important;\n }\n .d-xl-grid {\n display: grid !important;\n }\n .d-xl-inline-grid {\n display: inline-grid !important;\n }\n .d-xl-table {\n display: table !important;\n }\n .d-xl-table-row {\n display: table-row !important;\n }\n .d-xl-table-cell {\n display: table-cell !important;\n }\n .d-xl-flex {\n display: flex !important;\n }\n .d-xl-inline-flex {\n display: inline-flex !important;\n }\n .d-xl-none {\n display: none !important;\n }\n .flex-xl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xl-row {\n flex-direction: row !important;\n }\n .flex-xl-column {\n flex-direction: column !important;\n }\n .flex-xl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xl-center {\n justify-content: center !important;\n }\n .justify-content-xl-between {\n justify-content: space-between !important;\n }\n .justify-content-xl-around {\n justify-content: space-around !important;\n }\n .justify-content-xl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xl-start {\n align-items: flex-start !important;\n }\n .align-items-xl-end {\n align-items: flex-end !important;\n }\n .align-items-xl-center {\n align-items: center !important;\n }\n .align-items-xl-baseline {\n align-items: baseline !important;\n }\n .align-items-xl-stretch {\n align-items: stretch !important;\n }\n .align-content-xl-start {\n align-content: flex-start !important;\n }\n .align-content-xl-end {\n align-content: flex-end !important;\n }\n .align-content-xl-center {\n align-content: center !important;\n }\n .align-content-xl-between {\n align-content: space-between !important;\n }\n .align-content-xl-around {\n align-content: space-around !important;\n }\n .align-content-xl-stretch {\n align-content: stretch !important;\n }\n .align-self-xl-auto {\n align-self: auto !important;\n }\n .align-self-xl-start {\n align-self: flex-start !important;\n }\n .align-self-xl-end {\n align-self: flex-end !important;\n }\n .align-self-xl-center {\n align-self: center !important;\n }\n .align-self-xl-baseline {\n align-self: baseline !important;\n }\n .align-self-xl-stretch {\n align-self: stretch !important;\n }\n .order-xl-first {\n order: -1 !important;\n }\n .order-xl-0 {\n order: 0 !important;\n }\n .order-xl-1 {\n order: 1 !important;\n }\n .order-xl-2 {\n order: 2 !important;\n }\n .order-xl-3 {\n order: 3 !important;\n }\n .order-xl-4 {\n order: 4 !important;\n }\n .order-xl-5 {\n order: 5 !important;\n }\n .order-xl-last {\n order: 6 !important;\n }\n .m-xl-0 {\n margin: 0 !important;\n }\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n .m-xl-3 {\n margin: 1rem !important;\n }\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n .m-xl-5 {\n margin: 3rem !important;\n }\n .m-xl-auto {\n margin: auto !important;\n }\n .mx-xl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xl-0 {\n margin-top: 0 !important;\n }\n .mt-xl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xl-3 {\n margin-top: 1rem !important;\n }\n .mt-xl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xl-5 {\n margin-top: 3rem !important;\n }\n .mt-xl-auto {\n margin-top: auto !important;\n }\n .me-xl-0 {\n margin-right: 0 !important;\n }\n .me-xl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xl-3 {\n margin-right: 1rem !important;\n }\n .me-xl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xl-5 {\n margin-right: 3rem !important;\n }\n .me-xl-auto {\n margin-right: auto !important;\n }\n .mb-xl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xl-auto {\n margin-bottom: auto !important;\n }\n .ms-xl-0 {\n margin-left: 0 !important;\n }\n .ms-xl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xl-3 {\n margin-left: 1rem !important;\n }\n .ms-xl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xl-5 {\n margin-left: 3rem !important;\n }\n .ms-xl-auto {\n margin-left: auto !important;\n }\n .p-xl-0 {\n padding: 0 !important;\n }\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n .p-xl-3 {\n padding: 1rem !important;\n }\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n .p-xl-5 {\n padding: 3rem !important;\n }\n .px-xl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xl-0 {\n padding-top: 0 !important;\n }\n .pt-xl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xl-3 {\n padding-top: 1rem !important;\n }\n .pt-xl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xl-5 {\n padding-top: 3rem !important;\n }\n .pe-xl-0 {\n padding-right: 0 !important;\n }\n .pe-xl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xl-3 {\n padding-right: 1rem !important;\n }\n .pe-xl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xl-5 {\n padding-right: 3rem !important;\n }\n .pb-xl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xl-0 {\n padding-left: 0 !important;\n }\n .ps-xl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xl-3 {\n padding-left: 1rem !important;\n }\n .ps-xl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xl-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 1400px) {\n .d-xxl-inline {\n display: inline !important;\n }\n .d-xxl-inline-block {\n display: inline-block !important;\n }\n .d-xxl-block {\n display: block !important;\n }\n .d-xxl-grid {\n display: grid !important;\n }\n .d-xxl-inline-grid {\n display: inline-grid !important;\n }\n .d-xxl-table {\n display: table !important;\n }\n .d-xxl-table-row {\n display: table-row !important;\n }\n .d-xxl-table-cell {\n display: table-cell !important;\n }\n .d-xxl-flex {\n display: flex !important;\n }\n .d-xxl-inline-flex {\n display: inline-flex !important;\n }\n .d-xxl-none {\n display: none !important;\n }\n .flex-xxl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xxl-row {\n flex-direction: row !important;\n }\n .flex-xxl-column {\n flex-direction: column !important;\n }\n .flex-xxl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xxl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xxl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xxl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xxl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xxl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xxl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xxl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xxl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xxl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xxl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xxl-center {\n justify-content: center !important;\n }\n .justify-content-xxl-between {\n justify-content: space-between !important;\n }\n .justify-content-xxl-around {\n justify-content: space-around !important;\n }\n .justify-content-xxl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xxl-start {\n align-items: flex-start !important;\n }\n .align-items-xxl-end {\n align-items: flex-end !important;\n }\n .align-items-xxl-center {\n align-items: center !important;\n }\n .align-items-xxl-baseline {\n align-items: baseline !important;\n }\n .align-items-xxl-stretch {\n align-items: stretch !important;\n }\n .align-content-xxl-start {\n align-content: flex-start !important;\n }\n .align-content-xxl-end {\n align-content: flex-end !important;\n }\n .align-content-xxl-center {\n align-content: center !important;\n }\n .align-content-xxl-between {\n align-content: space-between !important;\n }\n .align-content-xxl-around {\n align-content: space-around !important;\n }\n .align-content-xxl-stretch {\n align-content: stretch !important;\n }\n .align-self-xxl-auto {\n align-self: auto !important;\n }\n .align-self-xxl-start {\n align-self: flex-start !important;\n }\n .align-self-xxl-end {\n align-self: flex-end !important;\n }\n .align-self-xxl-center {\n align-self: center !important;\n }\n .align-self-xxl-baseline {\n align-self: baseline !important;\n }\n .align-self-xxl-stretch {\n align-self: stretch !important;\n }\n .order-xxl-first {\n order: -1 !important;\n }\n .order-xxl-0 {\n order: 0 !important;\n }\n .order-xxl-1 {\n order: 1 !important;\n }\n .order-xxl-2 {\n order: 2 !important;\n }\n .order-xxl-3 {\n order: 3 !important;\n }\n .order-xxl-4 {\n order: 4 !important;\n }\n .order-xxl-5 {\n order: 5 !important;\n }\n .order-xxl-last {\n order: 6 !important;\n }\n .m-xxl-0 {\n margin: 0 !important;\n }\n .m-xxl-1 {\n margin: 0.25rem !important;\n }\n .m-xxl-2 {\n margin: 0.5rem !important;\n }\n .m-xxl-3 {\n margin: 1rem !important;\n }\n .m-xxl-4 {\n margin: 1.5rem !important;\n }\n .m-xxl-5 {\n margin: 3rem !important;\n }\n .m-xxl-auto {\n margin: auto !important;\n }\n .mx-xxl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xxl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xxl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xxl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xxl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xxl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xxl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xxl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xxl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xxl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xxl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xxl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xxl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xxl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xxl-0 {\n margin-top: 0 !important;\n }\n .mt-xxl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xxl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xxl-3 {\n margin-top: 1rem !important;\n }\n .mt-xxl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xxl-5 {\n margin-top: 3rem !important;\n }\n .mt-xxl-auto {\n margin-top: auto !important;\n }\n .me-xxl-0 {\n margin-right: 0 !important;\n }\n .me-xxl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xxl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xxl-3 {\n margin-right: 1rem !important;\n }\n .me-xxl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xxl-5 {\n margin-right: 3rem !important;\n }\n .me-xxl-auto {\n margin-right: auto !important;\n }\n .mb-xxl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xxl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xxl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xxl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xxl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xxl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xxl-auto {\n margin-bottom: auto !important;\n }\n .ms-xxl-0 {\n margin-left: 0 !important;\n }\n .ms-xxl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xxl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xxl-3 {\n margin-left: 1rem !important;\n }\n .ms-xxl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xxl-5 {\n margin-left: 3rem !important;\n }\n .ms-xxl-auto {\n margin-left: auto !important;\n }\n .p-xxl-0 {\n padding: 0 !important;\n }\n .p-xxl-1 {\n padding: 0.25rem !important;\n }\n .p-xxl-2 {\n padding: 0.5rem !important;\n }\n .p-xxl-3 {\n padding: 1rem !important;\n }\n .p-xxl-4 {\n padding: 1.5rem !important;\n }\n .p-xxl-5 {\n padding: 3rem !important;\n }\n .px-xxl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xxl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xxl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xxl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xxl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xxl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xxl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xxl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xxl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xxl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xxl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xxl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xxl-0 {\n padding-top: 0 !important;\n }\n .pt-xxl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xxl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xxl-3 {\n padding-top: 1rem !important;\n }\n .pt-xxl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xxl-5 {\n padding-top: 3rem !important;\n }\n .pe-xxl-0 {\n padding-right: 0 !important;\n }\n .pe-xxl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xxl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xxl-3 {\n padding-right: 1rem !important;\n }\n .pe-xxl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xxl-5 {\n padding-right: 3rem !important;\n }\n .pb-xxl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xxl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xxl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xxl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xxl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xxl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xxl-0 {\n padding-left: 0 !important;\n }\n .ps-xxl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xxl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xxl-3 {\n padding-left: 1rem !important;\n }\n .ps-xxl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xxl-5 {\n padding-left: 3rem !important;\n }\n}\n@media print {\n .d-print-inline {\n display: inline !important;\n }\n .d-print-inline-block {\n display: inline-block !important;\n }\n .d-print-block {\n display: block !important;\n }\n .d-print-grid {\n display: grid !important;\n }\n .d-print-inline-grid {\n display: inline-grid !important;\n }\n .d-print-table {\n display: table !important;\n }\n .d-print-table-row {\n display: table-row !important;\n }\n .d-print-table-cell {\n display: table-cell !important;\n }\n .d-print-flex {\n display: flex !important;\n }\n .d-print-inline-flex {\n display: inline-flex !important;\n }\n .d-print-none {\n display: none !important;\n }\n}\n\n/*# sourceMappingURL=bootstrap-grid.css.map */","// Container mixins\n\n@mixin make-container($gutter: $container-padding-x) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-right: auto;\n margin-left: auto;\n}\n","// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)\n//\n// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n// >> breakpoint-next(sm)\n// md\n// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// md\n// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl))\n// md\n@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {\n $n: index($breakpoint-names, $name);\n @if not $n {\n @error \"breakpoint `#{$name}` not found in `#{$breakpoints}`\";\n }\n @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 576px\n@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {\n $min: map-get($breakpoints, $name);\n @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width.\n// The maximum value is reduced by 0.02px to work around the limitations of\n// `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 767.98px\n@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {\n $max: map-get($breakpoints, $name);\n @return if($max and $max > 0, $max - .02, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.\n// Useful for making responsive utilities.\n//\n// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"\" (Returns a blank string)\n// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"-sm\"\n@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {\n @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n @if $min {\n @media (min-width: $min) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {\n $max: breakpoint-max($name, $breakpoints);\n @if $max {\n @media (max-width: $max) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($lower, $breakpoints);\n $max: breakpoint-max($upper, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($lower, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($upper, $breakpoints) {\n @content;\n }\n }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n $next: breakpoint-next($name, $breakpoints);\n $max: breakpoint-max($next, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($name, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($next, $breakpoints) {\n @content;\n }\n }\n}\n","// Row\n//\n// Rows contain your columns.\n\n:root {\n @each $name, $value in $grid-breakpoints {\n --#{$prefix}breakpoint-#{$name}: #{$value};\n }\n}\n\n@if $enable-grid-classes {\n .row {\n @include make-row();\n\n > * {\n @include make-col-ready();\n }\n }\n}\n\n@if $enable-cssgrid {\n .grid {\n display: grid;\n grid-template-rows: repeat(var(--#{$prefix}rows, 1), 1fr);\n grid-template-columns: repeat(var(--#{$prefix}columns, #{$grid-columns}), 1fr);\n gap: var(--#{$prefix}gap, #{$grid-gutter-width});\n\n @include make-cssgrid();\n }\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n@if $enable-grid-classes {\n @include make-grid-columns();\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n@mixin make-row($gutter: $grid-gutter-width) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n // TODO: Revisit calc order after https://github.com/react-bootstrap/react-bootstrap/issues/6039 is fixed\n margin-top: calc(-1 * var(--#{$prefix}gutter-y)); // stylelint-disable-line function-disallowed-list\n margin-right: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n margin-left: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n}\n\n@mixin make-col-ready() {\n // Add box sizing if only the grid is loaded\n box-sizing: if(variable-exists(include-column-box-sizing) and $include-column-box-sizing, border-box, null);\n // Prevent columns from becoming too narrow when at smaller grid tiers by\n // always setting `width: 100%;`. This works because we set the width\n // later on to override this initial width.\n flex-shrink: 0;\n width: 100%;\n max-width: 100%; // Prevent `.col-auto`, `.col` (& responsive variants) from breaking out the grid\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-top: var(--#{$prefix}gutter-y);\n}\n\n@mixin make-col($size: false, $columns: $grid-columns) {\n @if $size {\n flex: 0 0 auto;\n width: percentage(divide($size, $columns));\n\n } @else {\n flex: 1 1 0;\n max-width: 100%;\n }\n}\n\n@mixin make-col-auto() {\n flex: 0 0 auto;\n width: auto;\n}\n\n@mixin make-col-offset($size, $columns: $grid-columns) {\n $num: divide($size, $columns);\n margin-left: if($num == 0, 0, percentage($num));\n}\n\n// Row columns\n//\n// Specify on a parent element(e.g., .row) to force immediate children into NN\n// number of columns. Supports wrapping to new lines, but does not do a Masonry\n// style grid.\n@mixin row-cols($count) {\n > * {\n flex: 0 0 auto;\n width: percentage(divide(1, $count));\n }\n}\n\n// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `$grid-columns`.\n\n@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n // Provide basic `.col-{bp}` classes for equal-width flexbox columns\n .col#{$infix} {\n flex: 1 0 0%; // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4\n }\n\n .row-cols#{$infix}-auto > * {\n @include make-col-auto();\n }\n\n @if $grid-row-columns > 0 {\n @for $i from 1 through $grid-row-columns {\n .row-cols#{$infix}-#{$i} {\n @include row-cols($i);\n }\n }\n }\n\n .col#{$infix}-auto {\n @include make-col-auto();\n }\n\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @include make-col($i, $columns);\n }\n }\n\n // `$columns - 1` because offsetting by the width of an entire row isn't possible\n @for $i from 0 through ($columns - 1) {\n @if not ($infix == \"\" and $i == 0) { // Avoid emitting useless .offset-0\n .offset#{$infix}-#{$i} {\n @include make-col-offset($i, $columns);\n }\n }\n }\n }\n\n // Gutters\n //\n // Make use of `.g-*`, `.gx-*` or `.gy-*` utilities to change spacing between the columns.\n @each $key, $value in $gutters {\n .g#{$infix}-#{$key},\n .gx#{$infix}-#{$key} {\n --#{$prefix}gutter-x: #{$value};\n }\n\n .g#{$infix}-#{$key},\n .gy#{$infix}-#{$key} {\n --#{$prefix}gutter-y: #{$value};\n }\n }\n }\n }\n}\n\n@mixin make-cssgrid($columns: $grid-columns, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .g-col#{$infix}-#{$i} {\n grid-column: auto / span $i;\n }\n }\n\n // Start with `1` because `0` is an invalid value.\n // Ends with `$columns - 1` because offsetting by the width of an entire row isn't possible.\n @for $i from 1 through ($columns - 1) {\n .g-start#{$infix}-#{$i} {\n grid-column-start: $i;\n }\n }\n }\n }\n }\n}\n","// Utility generator\n// Used to generate utilities & print utilities\n@mixin generate-utility($utility, $infix: \"\", $is-rfs-media-query: false) {\n $values: map-get($utility, values);\n\n // If the values are a list or string, convert it into a map\n @if type-of($values) == \"string\" or type-of(nth($values, 1)) != \"list\" {\n $values: zip($values, $values);\n }\n\n @each $key, $value in $values {\n $properties: map-get($utility, property);\n\n // Multiple properties are possible, for example with vertical or horizontal margins or paddings\n @if type-of($properties) == \"string\" {\n $properties: append((), $properties);\n }\n\n // Use custom class if present\n $property-class: if(map-has-key($utility, class), map-get($utility, class), nth($properties, 1));\n $property-class: if($property-class == null, \"\", $property-class);\n\n // Use custom CSS variable name if present, otherwise default to `class`\n $css-variable-name: if(map-has-key($utility, css-variable-name), map-get($utility, css-variable-name), map-get($utility, class));\n\n // State params to generate pseudo-classes\n $state: if(map-has-key($utility, state), map-get($utility, state), ());\n\n $infix: if($property-class == \"\" and str-slice($infix, 1, 1) == \"-\", str-slice($infix, 2), $infix);\n\n // Don't prefix if value key is null (e.g. with shadow class)\n $property-class-modifier: if($key, if($property-class == \"\" and $infix == \"\", \"\", \"-\") + $key, \"\");\n\n @if map-get($utility, rfs) {\n // Inside the media query\n @if $is-rfs-media-query {\n $val: rfs-value($value);\n\n // Do not render anything if fluid and non fluid values are the same\n $value: if($val == rfs-fluid-value($value), null, $val);\n }\n @else {\n $value: rfs-fluid-value($value);\n }\n }\n\n $is-css-var: map-get($utility, css-var);\n $is-local-vars: map-get($utility, local-vars);\n $is-rtl: map-get($utility, rtl);\n\n @if $value != null {\n @if $is-rtl == false {\n /* rtl:begin:remove */\n }\n\n @if $is-css-var {\n .#{$property-class + $infix + $property-class-modifier} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n }\n } @else {\n .#{$property-class + $infix + $property-class-modifier} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n }\n }\n\n @if $is-rtl == false {\n /* rtl:end:remove */\n }\n }\n }\n}\n","// Loop over each breakpoint\n@each $breakpoint in map-keys($grid-breakpoints) {\n\n // Generate media query if needed\n @include media-breakpoint-up($breakpoint) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix);\n }\n }\n }\n}\n\n// RFS rescaling\n@media (min-width: $rfs-mq-value) {\n @each $breakpoint in map-keys($grid-breakpoints) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n @if (map-get($grid-breakpoints, $breakpoint) < $rfs-breakpoint) {\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and map-get($utility, rfs) and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix, true);\n }\n }\n }\n }\n}\n\n\n// Print utilities\n@media print {\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Then check if the utility needs print styles\n @if type-of($utility) == \"map\" and map-get($utility, print) == true {\n @include generate-utility($utility, \"-print\");\n }\n }\n}\n"]} \ No newline at end of file diff --git a/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css new file mode 100644 index 0000000..25b243a --- /dev/null +++ b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css @@ -0,0 +1,4084 @@ +/*! + * Bootstrap Grid v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +.container, +.container-fluid, +.container-xxl, +.container-xl, +.container-lg, +.container-md, +.container-sm { + --bs-gutter-x: 1.5rem; + --bs-gutter-y: 0; + width: 100%; + padding-left: calc(var(--bs-gutter-x) * 0.5); + padding-right: calc(var(--bs-gutter-x) * 0.5); + margin-left: auto; + margin-right: auto; +} + +@media (min-width: 576px) { + .container-sm, .container { + max-width: 540px; + } +} +@media (min-width: 768px) { + .container-md, .container-sm, .container { + max-width: 720px; + } +} +@media (min-width: 992px) { + .container-lg, .container-md, .container-sm, .container { + max-width: 960px; + } +} +@media (min-width: 1200px) { + .container-xl, .container-lg, .container-md, .container-sm, .container { + max-width: 1140px; + } +} +@media (min-width: 1400px) { + .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container { + max-width: 1320px; + } +} +:root { + --bs-breakpoint-xs: 0; + --bs-breakpoint-sm: 576px; + --bs-breakpoint-md: 768px; + --bs-breakpoint-lg: 992px; + --bs-breakpoint-xl: 1200px; + --bs-breakpoint-xxl: 1400px; +} + +.row { + --bs-gutter-x: 1.5rem; + --bs-gutter-y: 0; + display: flex; + flex-wrap: wrap; + margin-top: calc(-1 * var(--bs-gutter-y)); + margin-left: calc(-0.5 * var(--bs-gutter-x)); + margin-right: calc(-0.5 * var(--bs-gutter-x)); +} +.row > * { + box-sizing: border-box; + flex-shrink: 0; + width: 100%; + max-width: 100%; + padding-left: calc(var(--bs-gutter-x) * 0.5); + padding-right: calc(var(--bs-gutter-x) * 0.5); + margin-top: var(--bs-gutter-y); +} + +.col { + flex: 1 0 0%; +} + +.row-cols-auto > * { + flex: 0 0 auto; + width: auto; +} + +.row-cols-1 > * { + flex: 0 0 auto; + width: 100%; +} + +.row-cols-2 > * { + flex: 0 0 auto; + width: 50%; +} + +.row-cols-3 > * { + flex: 0 0 auto; + width: 33.33333333%; +} + +.row-cols-4 > * { + flex: 0 0 auto; + width: 25%; +} + +.row-cols-5 > * { + flex: 0 0 auto; + width: 20%; +} + +.row-cols-6 > * { + flex: 0 0 auto; + width: 16.66666667%; +} + +.col-auto { + flex: 0 0 auto; + width: auto; +} + +.col-1 { + flex: 0 0 auto; + width: 8.33333333%; +} + +.col-2 { + flex: 0 0 auto; + width: 16.66666667%; +} + +.col-3 { + flex: 0 0 auto; + width: 25%; +} + +.col-4 { + flex: 0 0 auto; + width: 33.33333333%; +} + +.col-5 { + flex: 0 0 auto; + width: 41.66666667%; +} + +.col-6 { + flex: 0 0 auto; + width: 50%; +} + +.col-7 { + flex: 0 0 auto; + width: 58.33333333%; +} + +.col-8 { + flex: 0 0 auto; + width: 66.66666667%; +} + +.col-9 { + flex: 0 0 auto; + width: 75%; +} + +.col-10 { + flex: 0 0 auto; + width: 83.33333333%; +} + +.col-11 { + flex: 0 0 auto; + width: 91.66666667%; +} + +.col-12 { + flex: 0 0 auto; + width: 100%; +} + +.offset-1 { + margin-right: 8.33333333%; +} + +.offset-2 { + margin-right: 16.66666667%; +} + +.offset-3 { + margin-right: 25%; +} + +.offset-4 { + margin-right: 33.33333333%; +} + +.offset-5 { + margin-right: 41.66666667%; +} + +.offset-6 { + margin-right: 50%; +} + +.offset-7 { + margin-right: 58.33333333%; +} + +.offset-8 { + margin-right: 66.66666667%; +} + +.offset-9 { + margin-right: 75%; +} + +.offset-10 { + margin-right: 83.33333333%; +} + +.offset-11 { + margin-right: 91.66666667%; +} + +.g-0, +.gx-0 { + --bs-gutter-x: 0; +} + +.g-0, +.gy-0 { + --bs-gutter-y: 0; +} + +.g-1, +.gx-1 { + --bs-gutter-x: 0.25rem; +} + +.g-1, +.gy-1 { + --bs-gutter-y: 0.25rem; +} + +.g-2, +.gx-2 { + --bs-gutter-x: 0.5rem; +} + +.g-2, +.gy-2 { + --bs-gutter-y: 0.5rem; +} + +.g-3, +.gx-3 { + --bs-gutter-x: 1rem; +} + +.g-3, +.gy-3 { + --bs-gutter-y: 1rem; +} + +.g-4, +.gx-4 { + --bs-gutter-x: 1.5rem; +} + +.g-4, +.gy-4 { + --bs-gutter-y: 1.5rem; +} + +.g-5, +.gx-5 { + --bs-gutter-x: 3rem; +} + +.g-5, +.gy-5 { + --bs-gutter-y: 3rem; +} + +@media (min-width: 576px) { + .col-sm { + flex: 1 0 0%; + } + .row-cols-sm-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-sm-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-sm-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-sm-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-sm-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-sm-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-sm-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-sm-auto { + flex: 0 0 auto; + width: auto; + } + .col-sm-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-sm-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-sm-3 { + flex: 0 0 auto; + width: 25%; + } + .col-sm-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-sm-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-sm-6 { + flex: 0 0 auto; + width: 50%; + } + .col-sm-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-sm-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-sm-9 { + flex: 0 0 auto; + width: 75%; + } + .col-sm-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-sm-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-sm-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-sm-0 { + margin-right: 0; + } + .offset-sm-1 { + margin-right: 8.33333333%; + } + .offset-sm-2 { + margin-right: 16.66666667%; + } + .offset-sm-3 { + margin-right: 25%; + } + .offset-sm-4 { + margin-right: 33.33333333%; + } + .offset-sm-5 { + margin-right: 41.66666667%; + } + .offset-sm-6 { + margin-right: 50%; + } + .offset-sm-7 { + margin-right: 58.33333333%; + } + .offset-sm-8 { + margin-right: 66.66666667%; + } + .offset-sm-9 { + margin-right: 75%; + } + .offset-sm-10 { + margin-right: 83.33333333%; + } + .offset-sm-11 { + margin-right: 91.66666667%; + } + .g-sm-0, + .gx-sm-0 { + --bs-gutter-x: 0; + } + .g-sm-0, + .gy-sm-0 { + --bs-gutter-y: 0; + } + .g-sm-1, + .gx-sm-1 { + --bs-gutter-x: 0.25rem; + } + .g-sm-1, + .gy-sm-1 { + --bs-gutter-y: 0.25rem; + } + .g-sm-2, + .gx-sm-2 { + --bs-gutter-x: 0.5rem; + } + .g-sm-2, + .gy-sm-2 { + --bs-gutter-y: 0.5rem; + } + .g-sm-3, + .gx-sm-3 { + --bs-gutter-x: 1rem; + } + .g-sm-3, + .gy-sm-3 { + --bs-gutter-y: 1rem; + } + .g-sm-4, + .gx-sm-4 { + --bs-gutter-x: 1.5rem; + } + .g-sm-4, + .gy-sm-4 { + --bs-gutter-y: 1.5rem; + } + .g-sm-5, + .gx-sm-5 { + --bs-gutter-x: 3rem; + } + .g-sm-5, + .gy-sm-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 768px) { + .col-md { + flex: 1 0 0%; + } + .row-cols-md-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-md-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-md-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-md-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-md-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-md-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-md-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-md-auto { + flex: 0 0 auto; + width: auto; + } + .col-md-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-md-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-md-3 { + flex: 0 0 auto; + width: 25%; + } + .col-md-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-md-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-md-6 { + flex: 0 0 auto; + width: 50%; + } + .col-md-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-md-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-md-9 { + flex: 0 0 auto; + width: 75%; + } + .col-md-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-md-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-md-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-md-0 { + margin-right: 0; + } + .offset-md-1 { + margin-right: 8.33333333%; + } + .offset-md-2 { + margin-right: 16.66666667%; + } + .offset-md-3 { + margin-right: 25%; + } + .offset-md-4 { + margin-right: 33.33333333%; + } + .offset-md-5 { + margin-right: 41.66666667%; + } + .offset-md-6 { + margin-right: 50%; + } + .offset-md-7 { + margin-right: 58.33333333%; + } + .offset-md-8 { + margin-right: 66.66666667%; + } + .offset-md-9 { + margin-right: 75%; + } + .offset-md-10 { + margin-right: 83.33333333%; + } + .offset-md-11 { + margin-right: 91.66666667%; + } + .g-md-0, + .gx-md-0 { + --bs-gutter-x: 0; + } + .g-md-0, + .gy-md-0 { + --bs-gutter-y: 0; + } + .g-md-1, + .gx-md-1 { + --bs-gutter-x: 0.25rem; + } + .g-md-1, + .gy-md-1 { + --bs-gutter-y: 0.25rem; + } + .g-md-2, + .gx-md-2 { + --bs-gutter-x: 0.5rem; + } + .g-md-2, + .gy-md-2 { + --bs-gutter-y: 0.5rem; + } + .g-md-3, + .gx-md-3 { + --bs-gutter-x: 1rem; + } + .g-md-3, + .gy-md-3 { + --bs-gutter-y: 1rem; + } + .g-md-4, + .gx-md-4 { + --bs-gutter-x: 1.5rem; + } + .g-md-4, + .gy-md-4 { + --bs-gutter-y: 1.5rem; + } + .g-md-5, + .gx-md-5 { + --bs-gutter-x: 3rem; + } + .g-md-5, + .gy-md-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 992px) { + .col-lg { + flex: 1 0 0%; + } + .row-cols-lg-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-lg-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-lg-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-lg-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-lg-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-lg-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-lg-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-lg-auto { + flex: 0 0 auto; + width: auto; + } + .col-lg-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-lg-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-lg-3 { + flex: 0 0 auto; + width: 25%; + } + .col-lg-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-lg-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-lg-6 { + flex: 0 0 auto; + width: 50%; + } + .col-lg-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-lg-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-lg-9 { + flex: 0 0 auto; + width: 75%; + } + .col-lg-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-lg-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-lg-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-lg-0 { + margin-right: 0; + } + .offset-lg-1 { + margin-right: 8.33333333%; + } + .offset-lg-2 { + margin-right: 16.66666667%; + } + .offset-lg-3 { + margin-right: 25%; + } + .offset-lg-4 { + margin-right: 33.33333333%; + } + .offset-lg-5 { + margin-right: 41.66666667%; + } + .offset-lg-6 { + margin-right: 50%; + } + .offset-lg-7 { + margin-right: 58.33333333%; + } + .offset-lg-8 { + margin-right: 66.66666667%; + } + .offset-lg-9 { + margin-right: 75%; + } + .offset-lg-10 { + margin-right: 83.33333333%; + } + .offset-lg-11 { + margin-right: 91.66666667%; + } + .g-lg-0, + .gx-lg-0 { + --bs-gutter-x: 0; + } + .g-lg-0, + .gy-lg-0 { + --bs-gutter-y: 0; + } + .g-lg-1, + .gx-lg-1 { + --bs-gutter-x: 0.25rem; + } + .g-lg-1, + .gy-lg-1 { + --bs-gutter-y: 0.25rem; + } + .g-lg-2, + .gx-lg-2 { + --bs-gutter-x: 0.5rem; + } + .g-lg-2, + .gy-lg-2 { + --bs-gutter-y: 0.5rem; + } + .g-lg-3, + .gx-lg-3 { + --bs-gutter-x: 1rem; + } + .g-lg-3, + .gy-lg-3 { + --bs-gutter-y: 1rem; + } + .g-lg-4, + .gx-lg-4 { + --bs-gutter-x: 1.5rem; + } + .g-lg-4, + .gy-lg-4 { + --bs-gutter-y: 1.5rem; + } + .g-lg-5, + .gx-lg-5 { + --bs-gutter-x: 3rem; + } + .g-lg-5, + .gy-lg-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 1200px) { + .col-xl { + flex: 1 0 0%; + } + .row-cols-xl-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-xl-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-xl-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-xl-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-xl-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-xl-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-xl-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xl-auto { + flex: 0 0 auto; + width: auto; + } + .col-xl-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-xl-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xl-3 { + flex: 0 0 auto; + width: 25%; + } + .col-xl-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-xl-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-xl-6 { + flex: 0 0 auto; + width: 50%; + } + .col-xl-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-xl-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-xl-9 { + flex: 0 0 auto; + width: 75%; + } + .col-xl-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-xl-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-xl-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-xl-0 { + margin-right: 0; + } + .offset-xl-1 { + margin-right: 8.33333333%; + } + .offset-xl-2 { + margin-right: 16.66666667%; + } + .offset-xl-3 { + margin-right: 25%; + } + .offset-xl-4 { + margin-right: 33.33333333%; + } + .offset-xl-5 { + margin-right: 41.66666667%; + } + .offset-xl-6 { + margin-right: 50%; + } + .offset-xl-7 { + margin-right: 58.33333333%; + } + .offset-xl-8 { + margin-right: 66.66666667%; + } + .offset-xl-9 { + margin-right: 75%; + } + .offset-xl-10 { + margin-right: 83.33333333%; + } + .offset-xl-11 { + margin-right: 91.66666667%; + } + .g-xl-0, + .gx-xl-0 { + --bs-gutter-x: 0; + } + .g-xl-0, + .gy-xl-0 { + --bs-gutter-y: 0; + } + .g-xl-1, + .gx-xl-1 { + --bs-gutter-x: 0.25rem; + } + .g-xl-1, + .gy-xl-1 { + --bs-gutter-y: 0.25rem; + } + .g-xl-2, + .gx-xl-2 { + --bs-gutter-x: 0.5rem; + } + .g-xl-2, + .gy-xl-2 { + --bs-gutter-y: 0.5rem; + } + .g-xl-3, + .gx-xl-3 { + --bs-gutter-x: 1rem; + } + .g-xl-3, + .gy-xl-3 { + --bs-gutter-y: 1rem; + } + .g-xl-4, + .gx-xl-4 { + --bs-gutter-x: 1.5rem; + } + .g-xl-4, + .gy-xl-4 { + --bs-gutter-y: 1.5rem; + } + .g-xl-5, + .gx-xl-5 { + --bs-gutter-x: 3rem; + } + .g-xl-5, + .gy-xl-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 1400px) { + .col-xxl { + flex: 1 0 0%; + } + .row-cols-xxl-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-xxl-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-xxl-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-xxl-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-xxl-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-xxl-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-xxl-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xxl-auto { + flex: 0 0 auto; + width: auto; + } + .col-xxl-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-xxl-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xxl-3 { + flex: 0 0 auto; + width: 25%; + } + .col-xxl-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-xxl-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-xxl-6 { + flex: 0 0 auto; + width: 50%; + } + .col-xxl-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-xxl-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-xxl-9 { + flex: 0 0 auto; + width: 75%; + } + .col-xxl-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-xxl-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-xxl-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-xxl-0 { + margin-right: 0; + } + .offset-xxl-1 { + margin-right: 8.33333333%; + } + .offset-xxl-2 { + margin-right: 16.66666667%; + } + .offset-xxl-3 { + margin-right: 25%; + } + .offset-xxl-4 { + margin-right: 33.33333333%; + } + .offset-xxl-5 { + margin-right: 41.66666667%; + } + .offset-xxl-6 { + margin-right: 50%; + } + .offset-xxl-7 { + margin-right: 58.33333333%; + } + .offset-xxl-8 { + margin-right: 66.66666667%; + } + .offset-xxl-9 { + margin-right: 75%; + } + .offset-xxl-10 { + margin-right: 83.33333333%; + } + .offset-xxl-11 { + margin-right: 91.66666667%; + } + .g-xxl-0, + .gx-xxl-0 { + --bs-gutter-x: 0; + } + .g-xxl-0, + .gy-xxl-0 { + --bs-gutter-y: 0; + } + .g-xxl-1, + .gx-xxl-1 { + --bs-gutter-x: 0.25rem; + } + .g-xxl-1, + .gy-xxl-1 { + --bs-gutter-y: 0.25rem; + } + .g-xxl-2, + .gx-xxl-2 { + --bs-gutter-x: 0.5rem; + } + .g-xxl-2, + .gy-xxl-2 { + --bs-gutter-y: 0.5rem; + } + .g-xxl-3, + .gx-xxl-3 { + --bs-gutter-x: 1rem; + } + .g-xxl-3, + .gy-xxl-3 { + --bs-gutter-y: 1rem; + } + .g-xxl-4, + .gx-xxl-4 { + --bs-gutter-x: 1.5rem; + } + .g-xxl-4, + .gy-xxl-4 { + --bs-gutter-y: 1.5rem; + } + .g-xxl-5, + .gx-xxl-5 { + --bs-gutter-x: 3rem; + } + .g-xxl-5, + .gy-xxl-5 { + --bs-gutter-y: 3rem; + } +} +.d-inline { + display: inline !important; +} + +.d-inline-block { + display: inline-block !important; +} + +.d-block { + display: block !important; +} + +.d-grid { + display: grid !important; +} + +.d-inline-grid { + display: inline-grid !important; +} + +.d-table { + display: table !important; +} + +.d-table-row { + display: table-row !important; +} + +.d-table-cell { + display: table-cell !important; +} + +.d-flex { + display: flex !important; +} + +.d-inline-flex { + display: inline-flex !important; +} + +.d-none { + display: none !important; +} + +.flex-fill { + flex: 1 1 auto !important; +} + +.flex-row { + flex-direction: row !important; +} + +.flex-column { + flex-direction: column !important; +} + +.flex-row-reverse { + flex-direction: row-reverse !important; +} + +.flex-column-reverse { + flex-direction: column-reverse !important; +} + +.flex-grow-0 { + flex-grow: 0 !important; +} + +.flex-grow-1 { + flex-grow: 1 !important; +} + +.flex-shrink-0 { + flex-shrink: 0 !important; +} + +.flex-shrink-1 { + flex-shrink: 1 !important; +} + +.flex-wrap { + flex-wrap: wrap !important; +} + +.flex-nowrap { + flex-wrap: nowrap !important; +} + +.flex-wrap-reverse { + flex-wrap: wrap-reverse !important; +} + +.justify-content-start { + justify-content: flex-start !important; +} + +.justify-content-end { + justify-content: flex-end !important; +} + +.justify-content-center { + justify-content: center !important; +} + +.justify-content-between { + justify-content: space-between !important; +} + +.justify-content-around { + justify-content: space-around !important; +} + +.justify-content-evenly { + justify-content: space-evenly !important; +} + +.align-items-start { + align-items: flex-start !important; +} + +.align-items-end { + align-items: flex-end !important; +} + +.align-items-center { + align-items: center !important; +} + +.align-items-baseline { + align-items: baseline !important; +} + +.align-items-stretch { + align-items: stretch !important; +} + +.align-content-start { + align-content: flex-start !important; +} + +.align-content-end { + align-content: flex-end !important; +} + +.align-content-center { + align-content: center !important; +} + +.align-content-between { + align-content: space-between !important; +} + +.align-content-around { + align-content: space-around !important; +} + +.align-content-stretch { + align-content: stretch !important; +} + +.align-self-auto { + align-self: auto !important; +} + +.align-self-start { + align-self: flex-start !important; +} + +.align-self-end { + align-self: flex-end !important; +} + +.align-self-center { + align-self: center !important; +} + +.align-self-baseline { + align-self: baseline !important; +} + +.align-self-stretch { + align-self: stretch !important; +} + +.order-first { + order: -1 !important; +} + +.order-0 { + order: 0 !important; +} + +.order-1 { + order: 1 !important; +} + +.order-2 { + order: 2 !important; +} + +.order-3 { + order: 3 !important; +} + +.order-4 { + order: 4 !important; +} + +.order-5 { + order: 5 !important; +} + +.order-last { + order: 6 !important; +} + +.m-0 { + margin: 0 !important; +} + +.m-1 { + margin: 0.25rem !important; +} + +.m-2 { + margin: 0.5rem !important; +} + +.m-3 { + margin: 1rem !important; +} + +.m-4 { + margin: 1.5rem !important; +} + +.m-5 { + margin: 3rem !important; +} + +.m-auto { + margin: auto !important; +} + +.mx-0 { + margin-left: 0 !important; + margin-right: 0 !important; +} + +.mx-1 { + margin-left: 0.25rem !important; + margin-right: 0.25rem !important; +} + +.mx-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; +} + +.mx-3 { + margin-left: 1rem !important; + margin-right: 1rem !important; +} + +.mx-4 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; +} + +.mx-5 { + margin-left: 3rem !important; + margin-right: 3rem !important; +} + +.mx-auto { + margin-left: auto !important; + margin-right: auto !important; +} + +.my-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; +} + +.my-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; +} + +.my-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; +} + +.my-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; +} + +.my-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; +} + +.my-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; +} + +.my-auto { + margin-top: auto !important; + margin-bottom: auto !important; +} + +.mt-0 { + margin-top: 0 !important; +} + +.mt-1 { + margin-top: 0.25rem !important; +} + +.mt-2 { + margin-top: 0.5rem !important; +} + +.mt-3 { + margin-top: 1rem !important; +} + +.mt-4 { + margin-top: 1.5rem !important; +} + +.mt-5 { + margin-top: 3rem !important; +} + +.mt-auto { + margin-top: auto !important; +} + +.me-0 { + margin-left: 0 !important; +} + +.me-1 { + margin-left: 0.25rem !important; +} + +.me-2 { + margin-left: 0.5rem !important; +} + +.me-3 { + margin-left: 1rem !important; +} + +.me-4 { + margin-left: 1.5rem !important; +} + +.me-5 { + margin-left: 3rem !important; +} + +.me-auto { + margin-left: auto !important; +} + +.mb-0 { + margin-bottom: 0 !important; +} + +.mb-1 { + margin-bottom: 0.25rem !important; +} + +.mb-2 { + margin-bottom: 0.5rem !important; +} + +.mb-3 { + margin-bottom: 1rem !important; +} + +.mb-4 { + margin-bottom: 1.5rem !important; +} + +.mb-5 { + margin-bottom: 3rem !important; +} + +.mb-auto { + margin-bottom: auto !important; +} + +.ms-0 { + margin-right: 0 !important; +} + +.ms-1 { + margin-right: 0.25rem !important; +} + +.ms-2 { + margin-right: 0.5rem !important; +} + +.ms-3 { + margin-right: 1rem !important; +} + +.ms-4 { + margin-right: 1.5rem !important; +} + +.ms-5 { + margin-right: 3rem !important; +} + +.ms-auto { + margin-right: auto !important; +} + +.p-0 { + padding: 0 !important; +} + +.p-1 { + padding: 0.25rem !important; +} + +.p-2 { + padding: 0.5rem !important; +} + +.p-3 { + padding: 1rem !important; +} + +.p-4 { + padding: 1.5rem !important; +} + +.p-5 { + padding: 3rem !important; +} + +.px-0 { + padding-left: 0 !important; + padding-right: 0 !important; +} + +.px-1 { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; +} + +.px-2 { + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; +} + +.px-3 { + padding-left: 1rem !important; + padding-right: 1rem !important; +} + +.px-4 { + padding-left: 1.5rem !important; + padding-right: 1.5rem !important; +} + +.px-5 { + padding-left: 3rem !important; + padding-right: 3rem !important; +} + +.py-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.py-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; +} + +.py-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; +} + +.py-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; +} + +.py-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; +} + +.py-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; +} + +.pt-0 { + padding-top: 0 !important; +} + +.pt-1 { + padding-top: 0.25rem !important; +} + +.pt-2 { + padding-top: 0.5rem !important; +} + +.pt-3 { + padding-top: 1rem !important; +} + +.pt-4 { + padding-top: 1.5rem !important; +} + +.pt-5 { + padding-top: 3rem !important; +} + +.pe-0 { + padding-left: 0 !important; +} + +.pe-1 { + padding-left: 0.25rem !important; +} + +.pe-2 { + padding-left: 0.5rem !important; +} + +.pe-3 { + padding-left: 1rem !important; +} + +.pe-4 { + padding-left: 1.5rem !important; +} + +.pe-5 { + padding-left: 3rem !important; +} + +.pb-0 { + padding-bottom: 0 !important; +} + +.pb-1 { + padding-bottom: 0.25rem !important; +} + +.pb-2 { + padding-bottom: 0.5rem !important; +} + +.pb-3 { + padding-bottom: 1rem !important; +} + +.pb-4 { + padding-bottom: 1.5rem !important; +} + +.pb-5 { + padding-bottom: 3rem !important; +} + +.ps-0 { + padding-right: 0 !important; +} + +.ps-1 { + padding-right: 0.25rem !important; +} + +.ps-2 { + padding-right: 0.5rem !important; +} + +.ps-3 { + padding-right: 1rem !important; +} + +.ps-4 { + padding-right: 1.5rem !important; +} + +.ps-5 { + padding-right: 3rem !important; +} + +@media (min-width: 576px) { + .d-sm-inline { + display: inline !important; + } + .d-sm-inline-block { + display: inline-block !important; + } + .d-sm-block { + display: block !important; + } + .d-sm-grid { + display: grid !important; + } + .d-sm-inline-grid { + display: inline-grid !important; + } + .d-sm-table { + display: table !important; + } + .d-sm-table-row { + display: table-row !important; + } + .d-sm-table-cell { + display: table-cell !important; + } + .d-sm-flex { + display: flex !important; + } + .d-sm-inline-flex { + display: inline-flex !important; + } + .d-sm-none { + display: none !important; + } + .flex-sm-fill { + flex: 1 1 auto !important; + } + .flex-sm-row { + flex-direction: row !important; + } + .flex-sm-column { + flex-direction: column !important; + } + .flex-sm-row-reverse { + flex-direction: row-reverse !important; + } + .flex-sm-column-reverse { + flex-direction: column-reverse !important; + } + .flex-sm-grow-0 { + flex-grow: 0 !important; + } + .flex-sm-grow-1 { + flex-grow: 1 !important; + } + .flex-sm-shrink-0 { + flex-shrink: 0 !important; + } + .flex-sm-shrink-1 { + flex-shrink: 1 !important; + } + .flex-sm-wrap { + flex-wrap: wrap !important; + } + .flex-sm-nowrap { + flex-wrap: nowrap !important; + } + .flex-sm-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-sm-start { + justify-content: flex-start !important; + } + .justify-content-sm-end { + justify-content: flex-end !important; + } + .justify-content-sm-center { + justify-content: center !important; + } + .justify-content-sm-between { + justify-content: space-between !important; + } + .justify-content-sm-around { + justify-content: space-around !important; + } + .justify-content-sm-evenly { + justify-content: space-evenly !important; + } + .align-items-sm-start { + align-items: flex-start !important; + } + .align-items-sm-end { + align-items: flex-end !important; + } + .align-items-sm-center { + align-items: center !important; + } + .align-items-sm-baseline { + align-items: baseline !important; + } + .align-items-sm-stretch { + align-items: stretch !important; + } + .align-content-sm-start { + align-content: flex-start !important; + } + .align-content-sm-end { + align-content: flex-end !important; + } + .align-content-sm-center { + align-content: center !important; + } + .align-content-sm-between { + align-content: space-between !important; + } + .align-content-sm-around { + align-content: space-around !important; + } + .align-content-sm-stretch { + align-content: stretch !important; + } + .align-self-sm-auto { + align-self: auto !important; + } + .align-self-sm-start { + align-self: flex-start !important; + } + .align-self-sm-end { + align-self: flex-end !important; + } + .align-self-sm-center { + align-self: center !important; + } + .align-self-sm-baseline { + align-self: baseline !important; + } + .align-self-sm-stretch { + align-self: stretch !important; + } + .order-sm-first { + order: -1 !important; + } + .order-sm-0 { + order: 0 !important; + } + .order-sm-1 { + order: 1 !important; + } + .order-sm-2 { + order: 2 !important; + } + .order-sm-3 { + order: 3 !important; + } + .order-sm-4 { + order: 4 !important; + } + .order-sm-5 { + order: 5 !important; + } + .order-sm-last { + order: 6 !important; + } + .m-sm-0 { + margin: 0 !important; + } + .m-sm-1 { + margin: 0.25rem !important; + } + .m-sm-2 { + margin: 0.5rem !important; + } + .m-sm-3 { + margin: 1rem !important; + } + .m-sm-4 { + margin: 1.5rem !important; + } + .m-sm-5 { + margin: 3rem !important; + } + .m-sm-auto { + margin: auto !important; + } + .mx-sm-0 { + margin-left: 0 !important; + margin-right: 0 !important; + } + .mx-sm-1 { + margin-left: 0.25rem !important; + margin-right: 0.25rem !important; + } + .mx-sm-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; + } + .mx-sm-3 { + margin-left: 1rem !important; + margin-right: 1rem !important; + } + .mx-sm-4 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; + } + .mx-sm-5 { + margin-left: 3rem !important; + margin-right: 3rem !important; + } + .mx-sm-auto { + margin-left: auto !important; + margin-right: auto !important; + } + .my-sm-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-sm-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-sm-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-sm-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-sm-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-sm-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-sm-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-sm-0 { + margin-top: 0 !important; + } + .mt-sm-1 { + margin-top: 0.25rem !important; + } + .mt-sm-2 { + margin-top: 0.5rem !important; + } + .mt-sm-3 { + margin-top: 1rem !important; + } + .mt-sm-4 { + margin-top: 1.5rem !important; + } + .mt-sm-5 { + margin-top: 3rem !important; + } + .mt-sm-auto { + margin-top: auto !important; + } + .me-sm-0 { + margin-left: 0 !important; + } + .me-sm-1 { + margin-left: 0.25rem !important; + } + .me-sm-2 { + margin-left: 0.5rem !important; + } + .me-sm-3 { + margin-left: 1rem !important; + } + .me-sm-4 { + margin-left: 1.5rem !important; + } + .me-sm-5 { + margin-left: 3rem !important; + } + .me-sm-auto { + margin-left: auto !important; + } + .mb-sm-0 { + margin-bottom: 0 !important; + } + .mb-sm-1 { + margin-bottom: 0.25rem !important; + } + .mb-sm-2 { + margin-bottom: 0.5rem !important; + } + .mb-sm-3 { + margin-bottom: 1rem !important; + } + .mb-sm-4 { + margin-bottom: 1.5rem !important; + } + .mb-sm-5 { + margin-bottom: 3rem !important; + } + .mb-sm-auto { + margin-bottom: auto !important; + } + .ms-sm-0 { + margin-right: 0 !important; + } + .ms-sm-1 { + margin-right: 0.25rem !important; + } + .ms-sm-2 { + margin-right: 0.5rem !important; + } + .ms-sm-3 { + margin-right: 1rem !important; + } + .ms-sm-4 { + margin-right: 1.5rem !important; + } + .ms-sm-5 { + margin-right: 3rem !important; + } + .ms-sm-auto { + margin-right: auto !important; + } + .p-sm-0 { + padding: 0 !important; + } + .p-sm-1 { + padding: 0.25rem !important; + } + .p-sm-2 { + padding: 0.5rem !important; + } + .p-sm-3 { + padding: 1rem !important; + } + .p-sm-4 { + padding: 1.5rem !important; + } + .p-sm-5 { + padding: 3rem !important; + } + .px-sm-0 { + padding-left: 0 !important; + padding-right: 0 !important; + } + .px-sm-1 { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; + } + .px-sm-2 { + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; + } + .px-sm-3 { + padding-left: 1rem !important; + padding-right: 1rem !important; + } + .px-sm-4 { + padding-left: 1.5rem !important; + padding-right: 1.5rem !important; + } + .px-sm-5 { + padding-left: 3rem !important; + padding-right: 3rem !important; + } + .py-sm-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-sm-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-sm-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-sm-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-sm-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-sm-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-sm-0 { + padding-top: 0 !important; + } + .pt-sm-1 { + padding-top: 0.25rem !important; + } + .pt-sm-2 { + padding-top: 0.5rem !important; + } + .pt-sm-3 { + padding-top: 1rem !important; + } + .pt-sm-4 { + padding-top: 1.5rem !important; + } + .pt-sm-5 { + padding-top: 3rem !important; + } + .pe-sm-0 { + padding-left: 0 !important; + } + .pe-sm-1 { + padding-left: 0.25rem !important; + } + .pe-sm-2 { + padding-left: 0.5rem !important; + } + .pe-sm-3 { + padding-left: 1rem !important; + } + .pe-sm-4 { + padding-left: 1.5rem !important; + } + .pe-sm-5 { + padding-left: 3rem !important; + } + .pb-sm-0 { + padding-bottom: 0 !important; + } + .pb-sm-1 { + padding-bottom: 0.25rem !important; + } + .pb-sm-2 { + padding-bottom: 0.5rem !important; + } + .pb-sm-3 { + padding-bottom: 1rem !important; + } + .pb-sm-4 { + padding-bottom: 1.5rem !important; + } + .pb-sm-5 { + padding-bottom: 3rem !important; + } + .ps-sm-0 { + padding-right: 0 !important; + } + .ps-sm-1 { + padding-right: 0.25rem !important; + } + .ps-sm-2 { + padding-right: 0.5rem !important; + } + .ps-sm-3 { + padding-right: 1rem !important; + } + .ps-sm-4 { + padding-right: 1.5rem !important; + } + .ps-sm-5 { + padding-right: 3rem !important; + } +} +@media (min-width: 768px) { + .d-md-inline { + display: inline !important; + } + .d-md-inline-block { + display: inline-block !important; + } + .d-md-block { + display: block !important; + } + .d-md-grid { + display: grid !important; + } + .d-md-inline-grid { + display: inline-grid !important; + } + .d-md-table { + display: table !important; + } + .d-md-table-row { + display: table-row !important; + } + .d-md-table-cell { + display: table-cell !important; + } + .d-md-flex { + display: flex !important; + } + .d-md-inline-flex { + display: inline-flex !important; + } + .d-md-none { + display: none !important; + } + .flex-md-fill { + flex: 1 1 auto !important; + } + .flex-md-row { + flex-direction: row !important; + } + .flex-md-column { + flex-direction: column !important; + } + .flex-md-row-reverse { + flex-direction: row-reverse !important; + } + .flex-md-column-reverse { + flex-direction: column-reverse !important; + } + .flex-md-grow-0 { + flex-grow: 0 !important; + } + .flex-md-grow-1 { + flex-grow: 1 !important; + } + .flex-md-shrink-0 { + flex-shrink: 0 !important; + } + .flex-md-shrink-1 { + flex-shrink: 1 !important; + } + .flex-md-wrap { + flex-wrap: wrap !important; + } + .flex-md-nowrap { + flex-wrap: nowrap !important; + } + .flex-md-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-md-start { + justify-content: flex-start !important; + } + .justify-content-md-end { + justify-content: flex-end !important; + } + .justify-content-md-center { + justify-content: center !important; + } + .justify-content-md-between { + justify-content: space-between !important; + } + .justify-content-md-around { + justify-content: space-around !important; + } + .justify-content-md-evenly { + justify-content: space-evenly !important; + } + .align-items-md-start { + align-items: flex-start !important; + } + .align-items-md-end { + align-items: flex-end !important; + } + .align-items-md-center { + align-items: center !important; + } + .align-items-md-baseline { + align-items: baseline !important; + } + .align-items-md-stretch { + align-items: stretch !important; + } + .align-content-md-start { + align-content: flex-start !important; + } + .align-content-md-end { + align-content: flex-end !important; + } + .align-content-md-center { + align-content: center !important; + } + .align-content-md-between { + align-content: space-between !important; + } + .align-content-md-around { + align-content: space-around !important; + } + .align-content-md-stretch { + align-content: stretch !important; + } + .align-self-md-auto { + align-self: auto !important; + } + .align-self-md-start { + align-self: flex-start !important; + } + .align-self-md-end { + align-self: flex-end !important; + } + .align-self-md-center { + align-self: center !important; + } + .align-self-md-baseline { + align-self: baseline !important; + } + .align-self-md-stretch { + align-self: stretch !important; + } + .order-md-first { + order: -1 !important; + } + .order-md-0 { + order: 0 !important; + } + .order-md-1 { + order: 1 !important; + } + .order-md-2 { + order: 2 !important; + } + .order-md-3 { + order: 3 !important; + } + .order-md-4 { + order: 4 !important; + } + .order-md-5 { + order: 5 !important; + } + .order-md-last { + order: 6 !important; + } + .m-md-0 { + margin: 0 !important; + } + .m-md-1 { + margin: 0.25rem !important; + } + .m-md-2 { + margin: 0.5rem !important; + } + .m-md-3 { + margin: 1rem !important; + } + .m-md-4 { + margin: 1.5rem !important; + } + .m-md-5 { + margin: 3rem !important; + } + .m-md-auto { + margin: auto !important; + } + .mx-md-0 { + margin-left: 0 !important; + margin-right: 0 !important; + } + .mx-md-1 { + margin-left: 0.25rem !important; + margin-right: 0.25rem !important; + } + .mx-md-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; + } + .mx-md-3 { + margin-left: 1rem !important; + margin-right: 1rem !important; + } + .mx-md-4 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; + } + .mx-md-5 { + margin-left: 3rem !important; + margin-right: 3rem !important; + } + .mx-md-auto { + margin-left: auto !important; + margin-right: auto !important; + } + .my-md-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-md-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-md-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-md-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-md-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-md-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-md-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-md-0 { + margin-top: 0 !important; + } + .mt-md-1 { + margin-top: 0.25rem !important; + } + .mt-md-2 { + margin-top: 0.5rem !important; + } + .mt-md-3 { + margin-top: 1rem !important; + } + .mt-md-4 { + margin-top: 1.5rem !important; + } + .mt-md-5 { + margin-top: 3rem !important; + } + .mt-md-auto { + margin-top: auto !important; + } + .me-md-0 { + margin-left: 0 !important; + } + .me-md-1 { + margin-left: 0.25rem !important; + } + .me-md-2 { + margin-left: 0.5rem !important; + } + .me-md-3 { + margin-left: 1rem !important; + } + .me-md-4 { + margin-left: 1.5rem !important; + } + .me-md-5 { + margin-left: 3rem !important; + } + .me-md-auto { + margin-left: auto !important; + } + .mb-md-0 { + margin-bottom: 0 !important; + } + .mb-md-1 { + margin-bottom: 0.25rem !important; + } + .mb-md-2 { + margin-bottom: 0.5rem !important; + } + .mb-md-3 { + margin-bottom: 1rem !important; + } + .mb-md-4 { + margin-bottom: 1.5rem !important; + } + .mb-md-5 { + margin-bottom: 3rem !important; + } + .mb-md-auto { + margin-bottom: auto !important; + } + .ms-md-0 { + margin-right: 0 !important; + } + .ms-md-1 { + margin-right: 0.25rem !important; + } + .ms-md-2 { + margin-right: 0.5rem !important; + } + .ms-md-3 { + margin-right: 1rem !important; + } + .ms-md-4 { + margin-right: 1.5rem !important; + } + .ms-md-5 { + margin-right: 3rem !important; + } + .ms-md-auto { + margin-right: auto !important; + } + .p-md-0 { + padding: 0 !important; + } + .p-md-1 { + padding: 0.25rem !important; + } + .p-md-2 { + padding: 0.5rem !important; + } + .p-md-3 { + padding: 1rem !important; + } + .p-md-4 { + padding: 1.5rem !important; + } + .p-md-5 { + padding: 3rem !important; + } + .px-md-0 { + padding-left: 0 !important; + padding-right: 0 !important; + } + .px-md-1 { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; + } + .px-md-2 { + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; + } + .px-md-3 { + padding-left: 1rem !important; + padding-right: 1rem !important; + } + .px-md-4 { + padding-left: 1.5rem !important; + padding-right: 1.5rem !important; + } + .px-md-5 { + padding-left: 3rem !important; + padding-right: 3rem !important; + } + .py-md-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-md-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-md-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-md-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-md-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-md-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-md-0 { + padding-top: 0 !important; + } + .pt-md-1 { + padding-top: 0.25rem !important; + } + .pt-md-2 { + padding-top: 0.5rem !important; + } + .pt-md-3 { + padding-top: 1rem !important; + } + .pt-md-4 { + padding-top: 1.5rem !important; + } + .pt-md-5 { + padding-top: 3rem !important; + } + .pe-md-0 { + padding-left: 0 !important; + } + .pe-md-1 { + padding-left: 0.25rem !important; + } + .pe-md-2 { + padding-left: 0.5rem !important; + } + .pe-md-3 { + padding-left: 1rem !important; + } + .pe-md-4 { + padding-left: 1.5rem !important; + } + .pe-md-5 { + padding-left: 3rem !important; + } + .pb-md-0 { + padding-bottom: 0 !important; + } + .pb-md-1 { + padding-bottom: 0.25rem !important; + } + .pb-md-2 { + padding-bottom: 0.5rem !important; + } + .pb-md-3 { + padding-bottom: 1rem !important; + } + .pb-md-4 { + padding-bottom: 1.5rem !important; + } + .pb-md-5 { + padding-bottom: 3rem !important; + } + .ps-md-0 { + padding-right: 0 !important; + } + .ps-md-1 { + padding-right: 0.25rem !important; + } + .ps-md-2 { + padding-right: 0.5rem !important; + } + .ps-md-3 { + padding-right: 1rem !important; + } + .ps-md-4 { + padding-right: 1.5rem !important; + } + .ps-md-5 { + padding-right: 3rem !important; + } +} +@media (min-width: 992px) { + .d-lg-inline { + display: inline !important; + } + .d-lg-inline-block { + display: inline-block !important; + } + .d-lg-block { + display: block !important; + } + .d-lg-grid { + display: grid !important; + } + .d-lg-inline-grid { + display: inline-grid !important; + } + .d-lg-table { + display: table !important; + } + .d-lg-table-row { + display: table-row !important; + } + .d-lg-table-cell { + display: table-cell !important; + } + .d-lg-flex { + display: flex !important; + } + .d-lg-inline-flex { + display: inline-flex !important; + } + .d-lg-none { + display: none !important; + } + .flex-lg-fill { + flex: 1 1 auto !important; + } + .flex-lg-row { + flex-direction: row !important; + } + .flex-lg-column { + flex-direction: column !important; + } + .flex-lg-row-reverse { + flex-direction: row-reverse !important; + } + .flex-lg-column-reverse { + flex-direction: column-reverse !important; + } + .flex-lg-grow-0 { + flex-grow: 0 !important; + } + .flex-lg-grow-1 { + flex-grow: 1 !important; + } + .flex-lg-shrink-0 { + flex-shrink: 0 !important; + } + .flex-lg-shrink-1 { + flex-shrink: 1 !important; + } + .flex-lg-wrap { + flex-wrap: wrap !important; + } + .flex-lg-nowrap { + flex-wrap: nowrap !important; + } + .flex-lg-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-lg-start { + justify-content: flex-start !important; + } + .justify-content-lg-end { + justify-content: flex-end !important; + } + .justify-content-lg-center { + justify-content: center !important; + } + .justify-content-lg-between { + justify-content: space-between !important; + } + .justify-content-lg-around { + justify-content: space-around !important; + } + .justify-content-lg-evenly { + justify-content: space-evenly !important; + } + .align-items-lg-start { + align-items: flex-start !important; + } + .align-items-lg-end { + align-items: flex-end !important; + } + .align-items-lg-center { + align-items: center !important; + } + .align-items-lg-baseline { + align-items: baseline !important; + } + .align-items-lg-stretch { + align-items: stretch !important; + } + .align-content-lg-start { + align-content: flex-start !important; + } + .align-content-lg-end { + align-content: flex-end !important; + } + .align-content-lg-center { + align-content: center !important; + } + .align-content-lg-between { + align-content: space-between !important; + } + .align-content-lg-around { + align-content: space-around !important; + } + .align-content-lg-stretch { + align-content: stretch !important; + } + .align-self-lg-auto { + align-self: auto !important; + } + .align-self-lg-start { + align-self: flex-start !important; + } + .align-self-lg-end { + align-self: flex-end !important; + } + .align-self-lg-center { + align-self: center !important; + } + .align-self-lg-baseline { + align-self: baseline !important; + } + .align-self-lg-stretch { + align-self: stretch !important; + } + .order-lg-first { + order: -1 !important; + } + .order-lg-0 { + order: 0 !important; + } + .order-lg-1 { + order: 1 !important; + } + .order-lg-2 { + order: 2 !important; + } + .order-lg-3 { + order: 3 !important; + } + .order-lg-4 { + order: 4 !important; + } + .order-lg-5 { + order: 5 !important; + } + .order-lg-last { + order: 6 !important; + } + .m-lg-0 { + margin: 0 !important; + } + .m-lg-1 { + margin: 0.25rem !important; + } + .m-lg-2 { + margin: 0.5rem !important; + } + .m-lg-3 { + margin: 1rem !important; + } + .m-lg-4 { + margin: 1.5rem !important; + } + .m-lg-5 { + margin: 3rem !important; + } + .m-lg-auto { + margin: auto !important; + } + .mx-lg-0 { + margin-left: 0 !important; + margin-right: 0 !important; + } + .mx-lg-1 { + margin-left: 0.25rem !important; + margin-right: 0.25rem !important; + } + .mx-lg-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; + } + .mx-lg-3 { + margin-left: 1rem !important; + margin-right: 1rem !important; + } + .mx-lg-4 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; + } + .mx-lg-5 { + margin-left: 3rem !important; + margin-right: 3rem !important; + } + .mx-lg-auto { + margin-left: auto !important; + margin-right: auto !important; + } + .my-lg-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-lg-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-lg-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-lg-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-lg-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-lg-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-lg-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-lg-0 { + margin-top: 0 !important; + } + .mt-lg-1 { + margin-top: 0.25rem !important; + } + .mt-lg-2 { + margin-top: 0.5rem !important; + } + .mt-lg-3 { + margin-top: 1rem !important; + } + .mt-lg-4 { + margin-top: 1.5rem !important; + } + .mt-lg-5 { + margin-top: 3rem !important; + } + .mt-lg-auto { + margin-top: auto !important; + } + .me-lg-0 { + margin-left: 0 !important; + } + .me-lg-1 { + margin-left: 0.25rem !important; + } + .me-lg-2 { + margin-left: 0.5rem !important; + } + .me-lg-3 { + margin-left: 1rem !important; + } + .me-lg-4 { + margin-left: 1.5rem !important; + } + .me-lg-5 { + margin-left: 3rem !important; + } + .me-lg-auto { + margin-left: auto !important; + } + .mb-lg-0 { + margin-bottom: 0 !important; + } + .mb-lg-1 { + margin-bottom: 0.25rem !important; + } + .mb-lg-2 { + margin-bottom: 0.5rem !important; + } + .mb-lg-3 { + margin-bottom: 1rem !important; + } + .mb-lg-4 { + margin-bottom: 1.5rem !important; + } + .mb-lg-5 { + margin-bottom: 3rem !important; + } + .mb-lg-auto { + margin-bottom: auto !important; + } + .ms-lg-0 { + margin-right: 0 !important; + } + .ms-lg-1 { + margin-right: 0.25rem !important; + } + .ms-lg-2 { + margin-right: 0.5rem !important; + } + .ms-lg-3 { + margin-right: 1rem !important; + } + .ms-lg-4 { + margin-right: 1.5rem !important; + } + .ms-lg-5 { + margin-right: 3rem !important; + } + .ms-lg-auto { + margin-right: auto !important; + } + .p-lg-0 { + padding: 0 !important; + } + .p-lg-1 { + padding: 0.25rem !important; + } + .p-lg-2 { + padding: 0.5rem !important; + } + .p-lg-3 { + padding: 1rem !important; + } + .p-lg-4 { + padding: 1.5rem !important; + } + .p-lg-5 { + padding: 3rem !important; + } + .px-lg-0 { + padding-left: 0 !important; + padding-right: 0 !important; + } + .px-lg-1 { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; + } + .px-lg-2 { + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; + } + .px-lg-3 { + padding-left: 1rem !important; + padding-right: 1rem !important; + } + .px-lg-4 { + padding-left: 1.5rem !important; + padding-right: 1.5rem !important; + } + .px-lg-5 { + padding-left: 3rem !important; + padding-right: 3rem !important; + } + .py-lg-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-lg-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-lg-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-lg-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-lg-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-lg-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-lg-0 { + padding-top: 0 !important; + } + .pt-lg-1 { + padding-top: 0.25rem !important; + } + .pt-lg-2 { + padding-top: 0.5rem !important; + } + .pt-lg-3 { + padding-top: 1rem !important; + } + .pt-lg-4 { + padding-top: 1.5rem !important; + } + .pt-lg-5 { + padding-top: 3rem !important; + } + .pe-lg-0 { + padding-left: 0 !important; + } + .pe-lg-1 { + padding-left: 0.25rem !important; + } + .pe-lg-2 { + padding-left: 0.5rem !important; + } + .pe-lg-3 { + padding-left: 1rem !important; + } + .pe-lg-4 { + padding-left: 1.5rem !important; + } + .pe-lg-5 { + padding-left: 3rem !important; + } + .pb-lg-0 { + padding-bottom: 0 !important; + } + .pb-lg-1 { + padding-bottom: 0.25rem !important; + } + .pb-lg-2 { + padding-bottom: 0.5rem !important; + } + .pb-lg-3 { + padding-bottom: 1rem !important; + } + .pb-lg-4 { + padding-bottom: 1.5rem !important; + } + .pb-lg-5 { + padding-bottom: 3rem !important; + } + .ps-lg-0 { + padding-right: 0 !important; + } + .ps-lg-1 { + padding-right: 0.25rem !important; + } + .ps-lg-2 { + padding-right: 0.5rem !important; + } + .ps-lg-3 { + padding-right: 1rem !important; + } + .ps-lg-4 { + padding-right: 1.5rem !important; + } + .ps-lg-5 { + padding-right: 3rem !important; + } +} +@media (min-width: 1200px) { + .d-xl-inline { + display: inline !important; + } + .d-xl-inline-block { + display: inline-block !important; + } + .d-xl-block { + display: block !important; + } + .d-xl-grid { + display: grid !important; + } + .d-xl-inline-grid { + display: inline-grid !important; + } + .d-xl-table { + display: table !important; + } + .d-xl-table-row { + display: table-row !important; + } + .d-xl-table-cell { + display: table-cell !important; + } + .d-xl-flex { + display: flex !important; + } + .d-xl-inline-flex { + display: inline-flex !important; + } + .d-xl-none { + display: none !important; + } + .flex-xl-fill { + flex: 1 1 auto !important; + } + .flex-xl-row { + flex-direction: row !important; + } + .flex-xl-column { + flex-direction: column !important; + } + .flex-xl-row-reverse { + flex-direction: row-reverse !important; + } + .flex-xl-column-reverse { + flex-direction: column-reverse !important; + } + .flex-xl-grow-0 { + flex-grow: 0 !important; + } + .flex-xl-grow-1 { + flex-grow: 1 !important; + } + .flex-xl-shrink-0 { + flex-shrink: 0 !important; + } + .flex-xl-shrink-1 { + flex-shrink: 1 !important; + } + .flex-xl-wrap { + flex-wrap: wrap !important; + } + .flex-xl-nowrap { + flex-wrap: nowrap !important; + } + .flex-xl-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-xl-start { + justify-content: flex-start !important; + } + .justify-content-xl-end { + justify-content: flex-end !important; + } + .justify-content-xl-center { + justify-content: center !important; + } + .justify-content-xl-between { + justify-content: space-between !important; + } + .justify-content-xl-around { + justify-content: space-around !important; + } + .justify-content-xl-evenly { + justify-content: space-evenly !important; + } + .align-items-xl-start { + align-items: flex-start !important; + } + .align-items-xl-end { + align-items: flex-end !important; + } + .align-items-xl-center { + align-items: center !important; + } + .align-items-xl-baseline { + align-items: baseline !important; + } + .align-items-xl-stretch { + align-items: stretch !important; + } + .align-content-xl-start { + align-content: flex-start !important; + } + .align-content-xl-end { + align-content: flex-end !important; + } + .align-content-xl-center { + align-content: center !important; + } + .align-content-xl-between { + align-content: space-between !important; + } + .align-content-xl-around { + align-content: space-around !important; + } + .align-content-xl-stretch { + align-content: stretch !important; + } + .align-self-xl-auto { + align-self: auto !important; + } + .align-self-xl-start { + align-self: flex-start !important; + } + .align-self-xl-end { + align-self: flex-end !important; + } + .align-self-xl-center { + align-self: center !important; + } + .align-self-xl-baseline { + align-self: baseline !important; + } + .align-self-xl-stretch { + align-self: stretch !important; + } + .order-xl-first { + order: -1 !important; + } + .order-xl-0 { + order: 0 !important; + } + .order-xl-1 { + order: 1 !important; + } + .order-xl-2 { + order: 2 !important; + } + .order-xl-3 { + order: 3 !important; + } + .order-xl-4 { + order: 4 !important; + } + .order-xl-5 { + order: 5 !important; + } + .order-xl-last { + order: 6 !important; + } + .m-xl-0 { + margin: 0 !important; + } + .m-xl-1 { + margin: 0.25rem !important; + } + .m-xl-2 { + margin: 0.5rem !important; + } + .m-xl-3 { + margin: 1rem !important; + } + .m-xl-4 { + margin: 1.5rem !important; + } + .m-xl-5 { + margin: 3rem !important; + } + .m-xl-auto { + margin: auto !important; + } + .mx-xl-0 { + margin-left: 0 !important; + margin-right: 0 !important; + } + .mx-xl-1 { + margin-left: 0.25rem !important; + margin-right: 0.25rem !important; + } + .mx-xl-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; + } + .mx-xl-3 { + margin-left: 1rem !important; + margin-right: 1rem !important; + } + .mx-xl-4 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; + } + .mx-xl-5 { + margin-left: 3rem !important; + margin-right: 3rem !important; + } + .mx-xl-auto { + margin-left: auto !important; + margin-right: auto !important; + } + .my-xl-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-xl-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-xl-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-xl-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-xl-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-xl-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-xl-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-xl-0 { + margin-top: 0 !important; + } + .mt-xl-1 { + margin-top: 0.25rem !important; + } + .mt-xl-2 { + margin-top: 0.5rem !important; + } + .mt-xl-3 { + margin-top: 1rem !important; + } + .mt-xl-4 { + margin-top: 1.5rem !important; + } + .mt-xl-5 { + margin-top: 3rem !important; + } + .mt-xl-auto { + margin-top: auto !important; + } + .me-xl-0 { + margin-left: 0 !important; + } + .me-xl-1 { + margin-left: 0.25rem !important; + } + .me-xl-2 { + margin-left: 0.5rem !important; + } + .me-xl-3 { + margin-left: 1rem !important; + } + .me-xl-4 { + margin-left: 1.5rem !important; + } + .me-xl-5 { + margin-left: 3rem !important; + } + .me-xl-auto { + margin-left: auto !important; + } + .mb-xl-0 { + margin-bottom: 0 !important; + } + .mb-xl-1 { + margin-bottom: 0.25rem !important; + } + .mb-xl-2 { + margin-bottom: 0.5rem !important; + } + .mb-xl-3 { + margin-bottom: 1rem !important; + } + .mb-xl-4 { + margin-bottom: 1.5rem !important; + } + .mb-xl-5 { + margin-bottom: 3rem !important; + } + .mb-xl-auto { + margin-bottom: auto !important; + } + .ms-xl-0 { + margin-right: 0 !important; + } + .ms-xl-1 { + margin-right: 0.25rem !important; + } + .ms-xl-2 { + margin-right: 0.5rem !important; + } + .ms-xl-3 { + margin-right: 1rem !important; + } + .ms-xl-4 { + margin-right: 1.5rem !important; + } + .ms-xl-5 { + margin-right: 3rem !important; + } + .ms-xl-auto { + margin-right: auto !important; + } + .p-xl-0 { + padding: 0 !important; + } + .p-xl-1 { + padding: 0.25rem !important; + } + .p-xl-2 { + padding: 0.5rem !important; + } + .p-xl-3 { + padding: 1rem !important; + } + .p-xl-4 { + padding: 1.5rem !important; + } + .p-xl-5 { + padding: 3rem !important; + } + .px-xl-0 { + padding-left: 0 !important; + padding-right: 0 !important; + } + .px-xl-1 { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; + } + .px-xl-2 { + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; + } + .px-xl-3 { + padding-left: 1rem !important; + padding-right: 1rem !important; + } + .px-xl-4 { + padding-left: 1.5rem !important; + padding-right: 1.5rem !important; + } + .px-xl-5 { + padding-left: 3rem !important; + padding-right: 3rem !important; + } + .py-xl-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-xl-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-xl-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-xl-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-xl-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-xl-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-xl-0 { + padding-top: 0 !important; + } + .pt-xl-1 { + padding-top: 0.25rem !important; + } + .pt-xl-2 { + padding-top: 0.5rem !important; + } + .pt-xl-3 { + padding-top: 1rem !important; + } + .pt-xl-4 { + padding-top: 1.5rem !important; + } + .pt-xl-5 { + padding-top: 3rem !important; + } + .pe-xl-0 { + padding-left: 0 !important; + } + .pe-xl-1 { + padding-left: 0.25rem !important; + } + .pe-xl-2 { + padding-left: 0.5rem !important; + } + .pe-xl-3 { + padding-left: 1rem !important; + } + .pe-xl-4 { + padding-left: 1.5rem !important; + } + .pe-xl-5 { + padding-left: 3rem !important; + } + .pb-xl-0 { + padding-bottom: 0 !important; + } + .pb-xl-1 { + padding-bottom: 0.25rem !important; + } + .pb-xl-2 { + padding-bottom: 0.5rem !important; + } + .pb-xl-3 { + padding-bottom: 1rem !important; + } + .pb-xl-4 { + padding-bottom: 1.5rem !important; + } + .pb-xl-5 { + padding-bottom: 3rem !important; + } + .ps-xl-0 { + padding-right: 0 !important; + } + .ps-xl-1 { + padding-right: 0.25rem !important; + } + .ps-xl-2 { + padding-right: 0.5rem !important; + } + .ps-xl-3 { + padding-right: 1rem !important; + } + .ps-xl-4 { + padding-right: 1.5rem !important; + } + .ps-xl-5 { + padding-right: 3rem !important; + } +} +@media (min-width: 1400px) { + .d-xxl-inline { + display: inline !important; + } + .d-xxl-inline-block { + display: inline-block !important; + } + .d-xxl-block { + display: block !important; + } + .d-xxl-grid { + display: grid !important; + } + .d-xxl-inline-grid { + display: inline-grid !important; + } + .d-xxl-table { + display: table !important; + } + .d-xxl-table-row { + display: table-row !important; + } + .d-xxl-table-cell { + display: table-cell !important; + } + .d-xxl-flex { + display: flex !important; + } + .d-xxl-inline-flex { + display: inline-flex !important; + } + .d-xxl-none { + display: none !important; + } + .flex-xxl-fill { + flex: 1 1 auto !important; + } + .flex-xxl-row { + flex-direction: row !important; + } + .flex-xxl-column { + flex-direction: column !important; + } + .flex-xxl-row-reverse { + flex-direction: row-reverse !important; + } + .flex-xxl-column-reverse { + flex-direction: column-reverse !important; + } + .flex-xxl-grow-0 { + flex-grow: 0 !important; + } + .flex-xxl-grow-1 { + flex-grow: 1 !important; + } + .flex-xxl-shrink-0 { + flex-shrink: 0 !important; + } + .flex-xxl-shrink-1 { + flex-shrink: 1 !important; + } + .flex-xxl-wrap { + flex-wrap: wrap !important; + } + .flex-xxl-nowrap { + flex-wrap: nowrap !important; + } + .flex-xxl-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-xxl-start { + justify-content: flex-start !important; + } + .justify-content-xxl-end { + justify-content: flex-end !important; + } + .justify-content-xxl-center { + justify-content: center !important; + } + .justify-content-xxl-between { + justify-content: space-between !important; + } + .justify-content-xxl-around { + justify-content: space-around !important; + } + .justify-content-xxl-evenly { + justify-content: space-evenly !important; + } + .align-items-xxl-start { + align-items: flex-start !important; + } + .align-items-xxl-end { + align-items: flex-end !important; + } + .align-items-xxl-center { + align-items: center !important; + } + .align-items-xxl-baseline { + align-items: baseline !important; + } + .align-items-xxl-stretch { + align-items: stretch !important; + } + .align-content-xxl-start { + align-content: flex-start !important; + } + .align-content-xxl-end { + align-content: flex-end !important; + } + .align-content-xxl-center { + align-content: center !important; + } + .align-content-xxl-between { + align-content: space-between !important; + } + .align-content-xxl-around { + align-content: space-around !important; + } + .align-content-xxl-stretch { + align-content: stretch !important; + } + .align-self-xxl-auto { + align-self: auto !important; + } + .align-self-xxl-start { + align-self: flex-start !important; + } + .align-self-xxl-end { + align-self: flex-end !important; + } + .align-self-xxl-center { + align-self: center !important; + } + .align-self-xxl-baseline { + align-self: baseline !important; + } + .align-self-xxl-stretch { + align-self: stretch !important; + } + .order-xxl-first { + order: -1 !important; + } + .order-xxl-0 { + order: 0 !important; + } + .order-xxl-1 { + order: 1 !important; + } + .order-xxl-2 { + order: 2 !important; + } + .order-xxl-3 { + order: 3 !important; + } + .order-xxl-4 { + order: 4 !important; + } + .order-xxl-5 { + order: 5 !important; + } + .order-xxl-last { + order: 6 !important; + } + .m-xxl-0 { + margin: 0 !important; + } + .m-xxl-1 { + margin: 0.25rem !important; + } + .m-xxl-2 { + margin: 0.5rem !important; + } + .m-xxl-3 { + margin: 1rem !important; + } + .m-xxl-4 { + margin: 1.5rem !important; + } + .m-xxl-5 { + margin: 3rem !important; + } + .m-xxl-auto { + margin: auto !important; + } + .mx-xxl-0 { + margin-left: 0 !important; + margin-right: 0 !important; + } + .mx-xxl-1 { + margin-left: 0.25rem !important; + margin-right: 0.25rem !important; + } + .mx-xxl-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; + } + .mx-xxl-3 { + margin-left: 1rem !important; + margin-right: 1rem !important; + } + .mx-xxl-4 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; + } + .mx-xxl-5 { + margin-left: 3rem !important; + margin-right: 3rem !important; + } + .mx-xxl-auto { + margin-left: auto !important; + margin-right: auto !important; + } + .my-xxl-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-xxl-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-xxl-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-xxl-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-xxl-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-xxl-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-xxl-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-xxl-0 { + margin-top: 0 !important; + } + .mt-xxl-1 { + margin-top: 0.25rem !important; + } + .mt-xxl-2 { + margin-top: 0.5rem !important; + } + .mt-xxl-3 { + margin-top: 1rem !important; + } + .mt-xxl-4 { + margin-top: 1.5rem !important; + } + .mt-xxl-5 { + margin-top: 3rem !important; + } + .mt-xxl-auto { + margin-top: auto !important; + } + .me-xxl-0 { + margin-left: 0 !important; + } + .me-xxl-1 { + margin-left: 0.25rem !important; + } + .me-xxl-2 { + margin-left: 0.5rem !important; + } + .me-xxl-3 { + margin-left: 1rem !important; + } + .me-xxl-4 { + margin-left: 1.5rem !important; + } + .me-xxl-5 { + margin-left: 3rem !important; + } + .me-xxl-auto { + margin-left: auto !important; + } + .mb-xxl-0 { + margin-bottom: 0 !important; + } + .mb-xxl-1 { + margin-bottom: 0.25rem !important; + } + .mb-xxl-2 { + margin-bottom: 0.5rem !important; + } + .mb-xxl-3 { + margin-bottom: 1rem !important; + } + .mb-xxl-4 { + margin-bottom: 1.5rem !important; + } + .mb-xxl-5 { + margin-bottom: 3rem !important; + } + .mb-xxl-auto { + margin-bottom: auto !important; + } + .ms-xxl-0 { + margin-right: 0 !important; + } + .ms-xxl-1 { + margin-right: 0.25rem !important; + } + .ms-xxl-2 { + margin-right: 0.5rem !important; + } + .ms-xxl-3 { + margin-right: 1rem !important; + } + .ms-xxl-4 { + margin-right: 1.5rem !important; + } + .ms-xxl-5 { + margin-right: 3rem !important; + } + .ms-xxl-auto { + margin-right: auto !important; + } + .p-xxl-0 { + padding: 0 !important; + } + .p-xxl-1 { + padding: 0.25rem !important; + } + .p-xxl-2 { + padding: 0.5rem !important; + } + .p-xxl-3 { + padding: 1rem !important; + } + .p-xxl-4 { + padding: 1.5rem !important; + } + .p-xxl-5 { + padding: 3rem !important; + } + .px-xxl-0 { + padding-left: 0 !important; + padding-right: 0 !important; + } + .px-xxl-1 { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; + } + .px-xxl-2 { + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; + } + .px-xxl-3 { + padding-left: 1rem !important; + padding-right: 1rem !important; + } + .px-xxl-4 { + padding-left: 1.5rem !important; + padding-right: 1.5rem !important; + } + .px-xxl-5 { + padding-left: 3rem !important; + padding-right: 3rem !important; + } + .py-xxl-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-xxl-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-xxl-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-xxl-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-xxl-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-xxl-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-xxl-0 { + padding-top: 0 !important; + } + .pt-xxl-1 { + padding-top: 0.25rem !important; + } + .pt-xxl-2 { + padding-top: 0.5rem !important; + } + .pt-xxl-3 { + padding-top: 1rem !important; + } + .pt-xxl-4 { + padding-top: 1.5rem !important; + } + .pt-xxl-5 { + padding-top: 3rem !important; + } + .pe-xxl-0 { + padding-left: 0 !important; + } + .pe-xxl-1 { + padding-left: 0.25rem !important; + } + .pe-xxl-2 { + padding-left: 0.5rem !important; + } + .pe-xxl-3 { + padding-left: 1rem !important; + } + .pe-xxl-4 { + padding-left: 1.5rem !important; + } + .pe-xxl-5 { + padding-left: 3rem !important; + } + .pb-xxl-0 { + padding-bottom: 0 !important; + } + .pb-xxl-1 { + padding-bottom: 0.25rem !important; + } + .pb-xxl-2 { + padding-bottom: 0.5rem !important; + } + .pb-xxl-3 { + padding-bottom: 1rem !important; + } + .pb-xxl-4 { + padding-bottom: 1.5rem !important; + } + .pb-xxl-5 { + padding-bottom: 3rem !important; + } + .ps-xxl-0 { + padding-right: 0 !important; + } + .ps-xxl-1 { + padding-right: 0.25rem !important; + } + .ps-xxl-2 { + padding-right: 0.5rem !important; + } + .ps-xxl-3 { + padding-right: 1rem !important; + } + .ps-xxl-4 { + padding-right: 1.5rem !important; + } + .ps-xxl-5 { + padding-right: 3rem !important; + } +} +@media print { + .d-print-inline { + display: inline !important; + } + .d-print-inline-block { + display: inline-block !important; + } + .d-print-block { + display: block !important; + } + .d-print-grid { + display: grid !important; + } + .d-print-inline-grid { + display: inline-grid !important; + } + .d-print-table { + display: table !important; + } + .d-print-table-row { + display: table-row !important; + } + .d-print-table-cell { + display: table-cell !important; + } + .d-print-flex { + display: flex !important; + } + .d-print-inline-flex { + display: inline-flex !important; + } + .d-print-none { + display: none !important; + } +} +/*# sourceMappingURL=bootstrap-grid.rtl.css.map */ \ No newline at end of file diff --git a/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map new file mode 100644 index 0000000..8df43cf --- /dev/null +++ b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","bootstrap-grid.css","../../scss/mixins/_breakpoints.scss","../../scss/_variables.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"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EE,mBAAA;EJ6+BR;EI1+BM;;IAEE,mBAAA;EJ4+BR;AACF;AKpiCQ;EAOI,0BAAA;ALgiCZ;;AKviCQ;EAOI,gCAAA;ALoiCZ;;AK3iCQ;EAOI,yBAAA;ALwiCZ;;AK/iCQ;EAOI,wBAAA;AL4iCZ;;AKnjCQ;EAOI,+BAAA;ALgjCZ;;AKvjCQ;EAOI,yBAAA;ALojCZ;;AK3jCQ;EAOI,6BAAA;ALwjCZ;;AK/jCQ;EAOI,8BAAA;AL4jCZ;;AKnkCQ;EAOI,wBAAA;ALgkCZ;;AKvkCQ;EAOI,+BAAA;ALokCZ;;AK3kCQ;EAOI,wBAAA;ALwkCZ;;AK/kCQ;EAOI,yBAAA;AL4kCZ;;AKnlCQ;EAOI,8BAAA;ALglCZ;;AKvlCQ;EAOI,iCAAA;ALolCZ;;AK3lCQ;EAOI,sCAAA;ALwlCZ;;AK/lCQ;EAOI,yCAAA;AL4lCZ;;AKnmCQ;EAOI,uBAAA;ALgmCZ;;AKvmCQ;EAOI,uBAAA;ALomCZ;;AK3mCQ;EAOI,yBAAA;ALwmCZ;;AK/mCQ;EAOI,yBAAA;AL4mCZ;;AKnnCQ;EAOI,0BAAA;ALgnCZ;;AKvnCQ;EAOI,4BAAA;ALonCZ;;AK3nCQ;EAOI,kCAAA;ALwnCZ;;AK/nCQ;EAOI,sCAAA;AL4nCZ;;AKnoCQ;EAOI,oCAAA;ALgoCZ;;AKvoCQ;EAOI,kCAAA;ALooCZ;;AK3oCQ;EAOI,yCAAA;ALwoCZ;;AK/oCQ;EAOI,wCAAA;AL4oCZ;;AKnpCQ;EAOI,wCAAA;ALgpCZ;;AKvpCQ;EAOI,kCAAA;ALopCZ;;AK3pCQ;EAOI,gCAAA;ALwpCZ;;AK/pCQ;EAOI,8BAAA;AL4pCZ;;AKnqCQ;EAOI,gCAAA;ALgqCZ;;AKvqCQ;EAOI,+BAAA;ALoqCZ;;AK3qCQ;EAOI,oCAAA;ALwqCZ;;AK/qCQ;EAOI,kCAAA;AL4qCZ;;AKnrCQ;EAOI,gCAAA;ALgrCZ;;AKvrCQ;EAOI,uCAAA;ALorCZ;;AK3rCQ;EAOI,sCAAA;ALwrCZ;;AK/rCQ;EAOI,iCAAA;AL4rCZ;;AKnsCQ;EAOI,2BAAA;ALgsCZ;;AKvsCQ;EAOI,iCAAA;ALosCZ;;AK3sCQ;EAOI,+BAAA;ALwsCZ;;AK/sCQ;EAOI,6BAAA;AL4sCZ;;AKntCQ;EA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;AL8iDZ;;AKrjDQ;EAOI,4BAAA;ALkjDZ;;AKzjDQ;EAOI,0BAAA;ALsjDZ;;AK7jDQ;EAOI,gCAAA;AL0jDZ;;AKjkDQ;EAOI,+BAAA;AL8jDZ;;AKrkDQ;EAOI,6BAAA;ALkkDZ;;AKzkDQ;EAOI,+BAAA;ALskDZ;;AK7kDQ;EAOI,6BAAA;AL0kDZ;;AKjlDQ;EAOI,4BAAA;AL8kDZ;;AKrlDQ;EAOI,kCAAA;ALklDZ;;AKzlDQ;EAOI,iCAAA;ALslDZ;;AK7lDQ;EAOI,+BAAA;AL0lDZ;;AKjmDQ;EAOI,iCAAA;AL8lDZ;;AKrmDQ;EAOI,+BAAA;ALkmDZ;;AKzmDQ;EAOI,2BAAA;ALsmDZ;;AK7mDQ;EAOI,iCAAA;AL0mDZ;;AKjnDQ;EAOI,gCAAA;AL8mDZ;;AKrnDQ;EAOI,8BAAA;ALknDZ;;AKznDQ;EAOI,gCAAA;ALsnDZ;;AK7nDQ;EAOI,8BAAA;AL0nDZ;;ACpoDI;EIGI;IAOI,0BAAA;EL+nDV;EKtoDM;IAOI,gCAAA;ELkoDV;EKzoDM;IAOI,yBAAA;ELqoDV;EK5oDM;IAOI,wBAAA;ELwoDV;EK/oDM;IAOI,+BAAA;EL2oDV;EKlpDM;IAOI,yBAAA;EL8oDV;EKrpDM;IAOI,6BAAA;ELipDV;EKxpDM;IAOI,8BAAA;ELopDV;EK3pDM;IAOI,wBAAA;ELupDV;EK9pDM;IAOI,+BAAA;EL0pDV;EKjqDM;IAOI,wBAAA;EL6pDV;EKpqDM;IAOI,yBAAA;ELgqDV;EKvqDM;IAOI,8BAAA;ELmqDV;EK1qDM;IAOI,iCAAA;ELsqDV;EK7qDM;IAOI,sCAAA;ELyqDV;EKhrDM;IAOI,yCAAA;EL4qDV;EKnrDM;IAOI,uBAAA;EL+qDV;EKtrDM;IAOI,uBAAA;ELkrDV;EKzrDM;IAOI,yBAAA;ELqrDV;EK5rDM;IAOI,yBAAA;ELwrDV;EK/rDM;IAOI,0BAAA;EL2rDV;EKlsDM;IAOI,4BAAA;EL8rDV;EKrsDM;IAOI,kCAAA;ELisDV;EKxsDM;IAOI,sCAAA;ELosDV;EK3sDM;IAOI,oCAAA;ELusDV;EK9sDM;IAOI,kCAAA;EL0sDV;EKjtDM;IAOI,yCAAA;EL6sDV;EKptDM;IAOI,wCAAA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nEV;EK/nEM;IAOI,yCAAA;EL2nEV;EKloEM;IAOI,uBAAA;EL8nEV;EKroEM;IAOI,uBAAA;ELioEV;EKxoEM;IAOI,yBAAA;ELooEV;EK3oEM;IAOI,yBAAA;ELuoEV;EK9oEM;IAOI,0BAAA;EL0oEV;EKjpEM;IAOI,4BAAA;EL6oEV;EKppEM;IAOI,kCAAA;ELgpEV;EKvpEM;IAOI,sCAAA;ELmpEV;EK1pEM;IAOI,oCAAA;ELspEV;EK7pEM;IAOI,kCAAA;ELypEV;EKhqEM;IAOI,yCAAA;EL4pEV;EKnqEM;IAOI,wCAAA;EL+pEV;EKtqEM;IAOI,wCAAA;ELkqEV;EKzqEM;IAOI,kCAAA;ELqqEV;EK5qEM;IAOI,gCAAA;ELwqEV;EK/qEM;IAOI,8BAAA;EL2qEV;EKlrEM;IAOI,gCAAA;EL8qEV;EKrrEM;IAOI,+BAAA;ELirEV;EKxrEM;IAOI,oCAAA;ELorEV;EK3rEM;IAOI,kCAAA;ELurEV;EK9rEM;IAOI,gCAAA;EL0rEV;EKjsEM;IAOI,uCAAA;EL6rEV;EKpsEM;IAOI,sCAAA;ELgsEV;EKvsEM;IAOI,iCAAA;ELmsEV;EK1sEM;IAOI,2BAAA;ELssEV;EK7sEM;IAOI,iCAAA;ELysEV;EKhtEM;IAOI,+BAAA;EL4sEV;EKntEM;IAOI,6BAAA;EL+sEV;EKttEM;IAOI,+BAAA;ELktEV;EKztEM;IAOI,8BAAA;ELqtEV;EK5tEM;IAOI,oBAAA;ELwtEV;EK/tEM;IAOI,mBAAA;EL2tEV;EKluEM;IAOI,mBAAA;EL8tEV;EKruEM;IAOI,mBAAA;ELiuEV;EKxuEM;IAOI,mBAAA;ELouEV;EK3uEM;IAOI,mBAAA;ELuuEV;EK9uEM;IAOI,mBAAA;EL0uEV;EKjvEM;IAOI,mBAAA;EL6uEV;EKpvEM;IAOI,oBAAA;ELgvEV;EKvvEM;IAOI,0BAAA;ELmvEV;EK1vEM;IAOI,yBAAA;ELsvEV;EK7vEM;IAOI,uBAAA;ELyvEV;EKhwEM;IAOI,yBAAA;EL4vEV;EKnwEM;IAOI,uBAAA;EL+vEV;EKtwEM;IAOI,uBAAA;ELkwEV;EKzwEM;IAOI,yBAAA;IAAA,0BAAA;ELswEV;EK7wEM;IAO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EKviFM;IAOI,yBAAA;ELmiFV;EK1iFM;IAOI,wBAAA;ELsiFV;EK7iFM;IAOI,+BAAA;ELyiFV;EKhjFM;IAOI,yBAAA;EL4iFV;EKnjFM;IAOI,6BAAA;EL+iFV;EKtjFM;IAOI,8BAAA;ELkjFV;EKzjFM;IAOI,wBAAA;ELqjFV;EK5jFM;IAOI,+BAAA;ELwjFV;EK/jFM;IAOI,wBAAA;EL2jFV;EKlkFM;IAOI,yBAAA;EL8jFV;EKrkFM;IAOI,8BAAA;ELikFV;EKxkFM;IAOI,iCAAA;ELokFV;EK3kFM;IAOI,sCAAA;ELukFV;EK9kFM;IAOI,yCAAA;EL0kFV;EKjlFM;IAOI,uBAAA;EL6kFV;EKplFM;IAOI,uBAAA;ELglFV;EKvlFM;IAOI,yBAAA;ELmlFV;EK1lFM;IAOI,yBAAA;ELslFV;EK7lFM;IAOI,0BAAA;ELylFV;EKhmFM;IAOI,4BAAA;EL4lFV;EKnmFM;IAOI,kCAAA;EL+lFV;EKtmFM;IAOI,sCAAA;ELkmFV;EKzmFM;IAOI,oCAAA;ELqmFV;EK5mFM;IAOI,kCAAA;ELwmFV;EK/mFM;IAOI,yCAAA;EL2mFV;EKlnFM;IAOI,wCAAA;EL8mFV;EKrnFM;IAOI,wCAAA;ELinFV;EKxnFM;IAOI,kCAAA;ELonFV;EK3nFM;IAOI,gCAAA;ELunFV;EK9nFM;IAOI,8BAAA;EL0nFV;EKjoFM;IAOI,gCAAA;EL6nFV;EKpoFM;IAOI,+BAAA;ELgoFV;EKvoFM;IAOI,oCAAA;ELmoFV;EK1oFM;IAOI,kCAAA;ELsoFV;EK7oFM;IAOI,gCAAA;ELyoFV;EKhpFM;IAOI,uCAAA;EL4oFV;EKnpFM;IAOI,sCAAA;EL+oFV;EKtpFM;IAOI,iCAAA;ELkpFV;EKzpFM;IAOI,2BAAA;ELqpFV;EK5pFM;IAOI,iCAAA;ELwpFV;EK/pFM;IAOI,+BAAA;EL2pFV;EKlqFM;IAOI,6BAAA;EL8pFV;EKrqFM;IAOI,+BAAA;ELiqFV;EKxqFM;IAOI,8BAAA;ELoqFV;EK3qFM;IAOI,oBAAA;ELuqFV;EK9qFM;IAOI,mBAAA;EL0qFV;EKjrFM;IAOI,mBAAA;EL6qFV;EKprFM;IAOI,mBAAA;ELgrFV;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AAA;ELy8FV;EKh9FM;IAOI,iCAAA;EL48FV;EKn9FM;IAOI,+BAAA;EL+8FV;EKt9FM;IAOI,iCAAA;ELk9FV;EKz9FM;IAOI,+BAAA;ELq9FV;EK59FM;IAOI,2BAAA;ELw9FV;EK/9FM;IAOI,iCAAA;EL29FV;EKl+FM;IAOI,gCAAA;EL89FV;EKr+FM;IAOI,8BAAA;ELi+FV;EKx+FM;IAOI,gCAAA;ELo+FV;EK3+FM;IAOI,8BAAA;ELu+FV;AACF;ACl/FI;EIGI;IAOI,0BAAA;EL4+FV;EKn/FM;IAOI,gCAAA;EL++FV;EKt/FM;IAOI,yBAAA;ELk/FV;EKz/FM;IAOI,wBAAA;ELq/FV;EK5/FM;IAOI,+BAAA;ELw/FV;EK//FM;IAOI,yBAAA;EL2/FV;EKlgGM;IAOI,6BAAA;EL8/FV;EKrgGM;IAOI,8BAAA;ELigGV;EKxgGM;IAOI,wBAAA;ELogGV;EK3gGM;IAOI,+BAAA;ELugGV;EK9gGM;IAOI,wBAAA;EL0gGV;EKjhGM;IAOI,yBAAA;EL6gGV;EKphGM;IAOI,8BAAA;ELghGV;EKvhGM;IAOI,iCAAA;ELmhGV;EK1hGM;IAOI,sCAAA;ELshGV;EK7hGM;IAOI,yCAAA;ELyhGV;EKhiGM;IAOI,uBAAA;EL4hGV;EKniGM;IAOI,uBAAA;EL+hGV;EKtiGM;IAOI,yBAAA;ELkiGV;EKziGM;IAOI,yBAAA;ELqiGV;EK5iGM;IAOI,0BAAA;ELwiGV;EK/iGM;IAOI,4BAAA;EL2iGV;EKljGM;IAOI,kCAAA;EL8iGV;EKrjGM;IAOI,sCAAA;ELijGV;EKxjGM;IAOI,oCAAA;ELojGV;EK3jGM;IAOI,kCAAA;ELujGV;EK9jGM;IAOI,yCAAA;EL0jGV;EKjkGM;IAOI,wCAAA;EL6jGV;EKpkGM;IAOI,wCAAA;ELgkGV;EKvkGM;IAOI,kCAAA;ELmkGV;EK1kGM;IAOI,gCAAA;ELskGV;EK7kGM;IAOI,8BAAA;ELykGV;EKhlGM;IAOI,gCAAA;EL4kGV;EKnlGM;IAOI,+BAAA;EL+kGV;EKtlGM;IAOI,oCAAA;ELklGV;EKzlGM;IAOI,kCAAA;ELqlGV;EK5lGM;IAOI,gCAAA;ELwlGV;EK/l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ELi3GV;EKx3GM;IAOI,+BAAA;ELo3GV;EK33GM;IAOI,8BAAA;ELu3GV;EK93GM;IAOI,4BAAA;EL03GV;EKj4GM;IAOI,8BAAA;EL63GV;EKp4GM;IAOI,4BAAA;ELg4GV;EKv4GM;IAOI,0BAAA;ELm4GV;EK14GM;IAOI,gCAAA;ELs4GV;EK74GM;IAOI,+BAAA;ELy4GV;EKh5GM;IAOI,6BAAA;EL44GV;EKn5GM;IAOI,+BAAA;EL+4GV;EKt5GM;IAOI,6BAAA;ELk5GV;EKz5GM;IAOI,4BAAA;ELq5GV;EK55GM;IAOI,kCAAA;ELw5GV;EK/5GM;IAOI,iCAAA;EL25GV;EKl6GM;IAOI,+BAAA;EL85GV;EKr6GM;IAOI,iCAAA;ELi6GV;EKx6GM;IAOI,+BAAA;ELo6GV;EK36GM;IAOI,2BAAA;ELu6GV;EK96GM;IAOI,iCAAA;EL06GV;EKj7GM;IAOI,gCAAA;EL66GV;EKp7GM;IAOI,8BAAA;ELg7GV;EKv7GM;IAOI,gCAAA;ELm7GV;EK17GM;IAOI,8BAAA;ELs7GV;AACF;ACj8GI;EIGI;IAOI,0BAAA;EL27GV;EKl8GM;IAOI,gCAAA;EL87GV;EKr8GM;IAOI,yBAAA;ELi8GV;EKx8GM;IAOI,wBAAA;ELo8GV;EK38GM;IAOI,+BAAA;ELu8GV;EK98GM;IAOI,yBAAA;EL08GV;EKj9GM;IAOI,6BAAA;EL68GV;EKp9GM;IAOI,8BAAA;ELg9GV;EKv9GM;IAOI,wBAAA;ELm9GV;EK19GM;IAOI,+BAAA;ELs9GV;EK79GM;IAOI,wBAAA;ELy9GV;EKh+GM;IAOI,yBAAA;EL49GV;EKn+GM;IAOI,8BAAA;EL+9GV;EKt+GM;IAOI,iCAAA;ELk+GV;EKz+GM;IAOI,sCAAA;ELq+GV;EK5+GM;IAOI,yCAAA;ELw+GV;EK/+GM;IAOI,uBAAA;EL2+GV;EKl/GM;IAOI,uBAAA;EL8+GV;EKr/GM;IAOI,yBAAA;ELi/GV;EKx/GM;IAOI,yBAAA;ELo/GV;EK3/GM;IAOI,0BAAA;ELu/GV;EK9/GM;IAOI,4BAAA;EL0/GV;EKjgHM;IAOI,kCAAA;EL6/GV;EKpgHM;IAOI,sCAAA;ELggHV;EKvgHM;I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","file":"bootstrap-grid.rtl.css","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n","// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n@if $enable-container-classes {\n // Single container class with breakpoint max-widths\n .container,\n // 100% wide container at all breakpoints\n .container-fluid {\n @include make-container();\n }\n\n // Responsive containers that are 100% wide until a breakpoint\n @each $breakpoint, $container-max-width in $container-max-widths {\n .container-#{$breakpoint} {\n @extend .container-fluid;\n }\n\n @include media-breakpoint-up($breakpoint, $grid-breakpoints) {\n %responsive-container-#{$breakpoint} {\n max-width: $container-max-width;\n }\n\n // Extend each breakpoint which is smaller or equal to the current breakpoint\n $extend-breakpoint: true;\n\n @each $name, $width in $grid-breakpoints {\n @if ($extend-breakpoint) {\n .container#{breakpoint-infix($name, $grid-breakpoints)} {\n @extend %responsive-container-#{$breakpoint};\n }\n\n // Once the current breakpoint is reached, stop extending\n @if ($breakpoint == $name) {\n $extend-breakpoint: false;\n }\n }\n }\n }\n }\n}\n","// Container mixins\n\n@mixin make-container($gutter: $container-padding-x) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-right: auto;\n margin-left: auto;\n}\n","/*!\n * Bootstrap Grid v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n.container,\n.container-fluid,\n.container-xxl,\n.container-xl,\n.container-lg,\n.container-md,\n.container-sm {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-right: auto;\n margin-left: auto;\n}\n\n@media (min-width: 576px) {\n .container-sm, .container {\n max-width: 540px;\n }\n}\n@media (min-width: 768px) {\n .container-md, .container-sm, .container {\n max-width: 720px;\n }\n}\n@media (min-width: 992px) {\n .container-lg, .container-md, .container-sm, .container {\n max-width: 960px;\n }\n}\n@media (min-width: 1200px) {\n .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1140px;\n }\n}\n@media (min-width: 1400px) {\n .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1320px;\n }\n}\n:root {\n --bs-breakpoint-xs: 0;\n --bs-breakpoint-sm: 576px;\n --bs-breakpoint-md: 768px;\n --bs-breakpoint-lg: 992px;\n --bs-breakpoint-xl: 1200px;\n --bs-breakpoint-xxl: 1400px;\n}\n\n.row {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n margin-top: calc(-1 * var(--bs-gutter-y));\n margin-right: calc(-0.5 * var(--bs-gutter-x));\n margin-left: calc(-0.5 * var(--bs-gutter-x));\n}\n.row > * {\n box-sizing: border-box;\n flex-shrink: 0;\n width: 100%;\n max-width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-top: var(--bs-gutter-y);\n}\n\n.col {\n flex: 1 0 0%;\n}\n\n.row-cols-auto > * {\n flex: 0 0 auto;\n width: auto;\n}\n\n.row-cols-1 > * {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.row-cols-2 > * {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.row-cols-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.row-cols-4 > * {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.row-cols-5 > * {\n flex: 0 0 auto;\n width: 20%;\n}\n\n.row-cols-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-auto {\n flex: 0 0 auto;\n width: auto;\n}\n\n.col-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n}\n\n.col-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-3 {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.col-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.col-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n}\n\n.col-6 {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.col-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n}\n\n.col-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n}\n\n.col-9 {\n flex: 0 0 auto;\n width: 75%;\n}\n\n.col-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n}\n\n.col-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n}\n\n.col-12 {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.offset-1 {\n margin-left: 8.33333333%;\n}\n\n.offset-2 {\n margin-left: 16.66666667%;\n}\n\n.offset-3 {\n margin-left: 25%;\n}\n\n.offset-4 {\n margin-left: 33.33333333%;\n}\n\n.offset-5 {\n margin-left: 41.66666667%;\n}\n\n.offset-6 {\n margin-left: 50%;\n}\n\n.offset-7 {\n margin-left: 58.33333333%;\n}\n\n.offset-8 {\n margin-left: 66.66666667%;\n}\n\n.offset-9 {\n margin-left: 75%;\n}\n\n.offset-10 {\n margin-left: 83.33333333%;\n}\n\n.offset-11 {\n margin-left: 91.66666667%;\n}\n\n.g-0,\n.gx-0 {\n --bs-gutter-x: 0;\n}\n\n.g-0,\n.gy-0 {\n --bs-gutter-y: 0;\n}\n\n.g-1,\n.gx-1 {\n --bs-gutter-x: 0.25rem;\n}\n\n.g-1,\n.gy-1 {\n --bs-gutter-y: 0.25rem;\n}\n\n.g-2,\n.gx-2 {\n --bs-gutter-x: 0.5rem;\n}\n\n.g-2,\n.gy-2 {\n --bs-gutter-y: 0.5rem;\n}\n\n.g-3,\n.gx-3 {\n --bs-gutter-x: 1rem;\n}\n\n.g-3,\n.gy-3 {\n --bs-gutter-y: 1rem;\n}\n\n.g-4,\n.gx-4 {\n --bs-gutter-x: 1.5rem;\n}\n\n.g-4,\n.gy-4 {\n --bs-gutter-y: 1.5rem;\n}\n\n.g-5,\n.gx-5 {\n --bs-gutter-x: 3rem;\n}\n\n.g-5,\n.gy-5 {\n --bs-gutter-y: 3rem;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n flex: 1 0 0%;\n }\n .row-cols-sm-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-sm-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-sm-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-sm-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-sm-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-sm-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-sm-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-sm-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-sm-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-sm-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-sm-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-sm-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-sm-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-sm-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-sm-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-sm-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-sm-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-sm-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-sm-0 {\n margin-left: 0;\n }\n .offset-sm-1 {\n margin-left: 8.33333333%;\n }\n .offset-sm-2 {\n margin-left: 16.66666667%;\n }\n .offset-sm-3 {\n margin-left: 25%;\n }\n .offset-sm-4 {\n margin-left: 33.33333333%;\n }\n .offset-sm-5 {\n margin-left: 41.66666667%;\n }\n .offset-sm-6 {\n margin-left: 50%;\n }\n .offset-sm-7 {\n margin-left: 58.33333333%;\n }\n .offset-sm-8 {\n margin-left: 66.66666667%;\n }\n .offset-sm-9 {\n margin-left: 75%;\n }\n .offset-sm-10 {\n margin-left: 83.33333333%;\n }\n .offset-sm-11 {\n margin-left: 91.66666667%;\n }\n .g-sm-0,\n .gx-sm-0 {\n --bs-gutter-x: 0;\n }\n .g-sm-0,\n .gy-sm-0 {\n --bs-gutter-y: 0;\n }\n .g-sm-1,\n .gx-sm-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-sm-1,\n .gy-sm-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-sm-2,\n .gx-sm-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-sm-2,\n .gy-sm-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-sm-3,\n .gx-sm-3 {\n --bs-gutter-x: 1rem;\n }\n .g-sm-3,\n .gy-sm-3 {\n --bs-gutter-y: 1rem;\n }\n .g-sm-4,\n .gx-sm-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-sm-4,\n .gy-sm-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-sm-5,\n .gx-sm-5 {\n --bs-gutter-x: 3rem;\n }\n .g-sm-5,\n .gy-sm-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 768px) {\n .col-md {\n flex: 1 0 0%;\n }\n .row-cols-md-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-md-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-md-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-md-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-md-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-md-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-md-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-md-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-md-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-md-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-md-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-md-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-md-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-md-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-md-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-md-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-md-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-md-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-md-0 {\n margin-left: 0;\n }\n .offset-md-1 {\n margin-left: 8.33333333%;\n }\n .offset-md-2 {\n margin-left: 16.66666667%;\n }\n .offset-md-3 {\n margin-left: 25%;\n }\n .offset-md-4 {\n margin-left: 33.33333333%;\n }\n .offset-md-5 {\n margin-left: 41.66666667%;\n }\n .offset-md-6 {\n margin-left: 50%;\n }\n .offset-md-7 {\n margin-left: 58.33333333%;\n }\n .offset-md-8 {\n margin-left: 66.66666667%;\n }\n .offset-md-9 {\n margin-left: 75%;\n }\n .offset-md-10 {\n margin-left: 83.33333333%;\n }\n .offset-md-11 {\n margin-left: 91.66666667%;\n }\n .g-md-0,\n .gx-md-0 {\n --bs-gutter-x: 0;\n }\n .g-md-0,\n .gy-md-0 {\n --bs-gutter-y: 0;\n }\n .g-md-1,\n .gx-md-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-md-1,\n .gy-md-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-md-2,\n .gx-md-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-md-2,\n .gy-md-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-md-3,\n .gx-md-3 {\n --bs-gutter-x: 1rem;\n }\n .g-md-3,\n .gy-md-3 {\n --bs-gutter-y: 1rem;\n }\n .g-md-4,\n .gx-md-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-md-4,\n .gy-md-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-md-5,\n .gx-md-5 {\n --bs-gutter-x: 3rem;\n }\n .g-md-5,\n .gy-md-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 992px) {\n .col-lg {\n flex: 1 0 0%;\n }\n .row-cols-lg-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-lg-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-lg-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-lg-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-lg-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-lg-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-lg-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-lg-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-lg-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-lg-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-lg-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-lg-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-lg-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-lg-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-lg-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-lg-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-lg-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-lg-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-lg-0 {\n margin-left: 0;\n }\n .offset-lg-1 {\n margin-left: 8.33333333%;\n }\n .offset-lg-2 {\n margin-left: 16.66666667%;\n }\n .offset-lg-3 {\n margin-left: 25%;\n }\n .offset-lg-4 {\n margin-left: 33.33333333%;\n }\n .offset-lg-5 {\n margin-left: 41.66666667%;\n }\n .offset-lg-6 {\n margin-left: 50%;\n }\n .offset-lg-7 {\n margin-left: 58.33333333%;\n }\n .offset-lg-8 {\n margin-left: 66.66666667%;\n }\n .offset-lg-9 {\n margin-left: 75%;\n }\n .offset-lg-10 {\n margin-left: 83.33333333%;\n }\n .offset-lg-11 {\n margin-left: 91.66666667%;\n }\n .g-lg-0,\n .gx-lg-0 {\n --bs-gutter-x: 0;\n }\n .g-lg-0,\n .gy-lg-0 {\n --bs-gutter-y: 0;\n }\n .g-lg-1,\n .gx-lg-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-lg-1,\n .gy-lg-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-lg-2,\n .gx-lg-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-lg-2,\n .gy-lg-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-lg-3,\n .gx-lg-3 {\n --bs-gutter-x: 1rem;\n }\n .g-lg-3,\n .gy-lg-3 {\n --bs-gutter-y: 1rem;\n }\n .g-lg-4,\n .gx-lg-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-lg-4,\n .gy-lg-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-lg-5,\n .gx-lg-5 {\n --bs-gutter-x: 3rem;\n }\n .g-lg-5,\n .gy-lg-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1200px) {\n .col-xl {\n flex: 1 0 0%;\n }\n .row-cols-xl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xl-0 {\n margin-left: 0;\n }\n .offset-xl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xl-3 {\n margin-left: 25%;\n }\n .offset-xl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xl-6 {\n margin-left: 50%;\n }\n .offset-xl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xl-9 {\n margin-left: 75%;\n }\n .offset-xl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xl-11 {\n margin-left: 91.66666667%;\n }\n .g-xl-0,\n .gx-xl-0 {\n --bs-gutter-x: 0;\n }\n .g-xl-0,\n .gy-xl-0 {\n --bs-gutter-y: 0;\n }\n .g-xl-1,\n .gx-xl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xl-1,\n .gy-xl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xl-2,\n .gx-xl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xl-2,\n .gy-xl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xl-3,\n .gx-xl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xl-3,\n .gy-xl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xl-4,\n .gx-xl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xl-4,\n .gy-xl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xl-5,\n .gx-xl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xl-5,\n .gy-xl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1400px) {\n .col-xxl {\n flex: 1 0 0%;\n }\n .row-cols-xxl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xxl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xxl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xxl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xxl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xxl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xxl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xxl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xxl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xxl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xxl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xxl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xxl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xxl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xxl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xxl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xxl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xxl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xxl-0 {\n margin-left: 0;\n }\n .offset-xxl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xxl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xxl-3 {\n margin-left: 25%;\n }\n .offset-xxl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xxl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xxl-6 {\n margin-left: 50%;\n }\n .offset-xxl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xxl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xxl-9 {\n margin-left: 75%;\n }\n .offset-xxl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xxl-11 {\n margin-left: 91.66666667%;\n }\n .g-xxl-0,\n .gx-xxl-0 {\n --bs-gutter-x: 0;\n }\n .g-xxl-0,\n .gy-xxl-0 {\n --bs-gutter-y: 0;\n }\n .g-xxl-1,\n .gx-xxl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xxl-1,\n .gy-xxl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xxl-2,\n .gx-xxl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xxl-2,\n .gy-xxl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xxl-3,\n .gx-xxl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xxl-3,\n .gy-xxl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xxl-4,\n .gx-xxl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xxl-4,\n .gy-xxl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xxl-5,\n .gx-xxl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xxl-5,\n .gy-xxl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-grid {\n display: grid !important;\n}\n\n.d-inline-grid {\n display: inline-grid !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: flex !important;\n}\n\n.d-inline-flex {\n display: inline-flex !important;\n}\n\n.d-none {\n display: none !important;\n}\n\n.flex-fill {\n flex: 1 1 auto !important;\n}\n\n.flex-row {\n flex-direction: row !important;\n}\n\n.flex-column {\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n flex-direction: column-reverse !important;\n}\n\n.flex-grow-0 {\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n flex-shrink: 1 !important;\n}\n\n.flex-wrap {\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n}\n\n.justify-content-start {\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n justify-content: center !important;\n}\n\n.justify-content-between {\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n justify-content: space-around !important;\n}\n\n.justify-content-evenly {\n justify-content: space-evenly !important;\n}\n\n.align-items-start {\n align-items: flex-start !important;\n}\n\n.align-items-end {\n align-items: flex-end !important;\n}\n\n.align-items-center {\n align-items: center !important;\n}\n\n.align-items-baseline {\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n align-items: stretch !important;\n}\n\n.align-content-start {\n align-content: flex-start !important;\n}\n\n.align-content-end {\n align-content: flex-end !important;\n}\n\n.align-content-center {\n align-content: center !important;\n}\n\n.align-content-between {\n align-content: space-between !important;\n}\n\n.align-content-around {\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n align-content: stretch !important;\n}\n\n.align-self-auto {\n align-self: auto !important;\n}\n\n.align-self-start {\n align-self: flex-start !important;\n}\n\n.align-self-end {\n align-self: flex-end !important;\n}\n\n.align-self-center {\n align-self: center !important;\n}\n\n.align-self-baseline {\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n align-self: stretch !important;\n}\n\n.order-first {\n order: -1 !important;\n}\n\n.order-0 {\n order: 0 !important;\n}\n\n.order-1 {\n order: 1 !important;\n}\n\n.order-2 {\n order: 2 !important;\n}\n\n.order-3 {\n order: 3 !important;\n}\n\n.order-4 {\n order: 4 !important;\n}\n\n.order-5 {\n order: 5 !important;\n}\n\n.order-last {\n order: 6 !important;\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mx-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n}\n\n.mx-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n}\n\n.mx-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n}\n\n.mx-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n}\n\n.mx-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n}\n\n.mx-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n}\n\n.mx-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n}\n\n.my-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n}\n\n.my-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n}\n\n.my-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n}\n\n.my-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n}\n\n.my-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n}\n\n.my-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n}\n\n.my-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n}\n\n.mt-0 {\n margin-top: 0 !important;\n}\n\n.mt-1 {\n margin-top: 0.25rem !important;\n}\n\n.mt-2 {\n margin-top: 0.5rem !important;\n}\n\n.mt-3 {\n margin-top: 1rem !important;\n}\n\n.mt-4 {\n margin-top: 1.5rem !important;\n}\n\n.mt-5 {\n margin-top: 3rem !important;\n}\n\n.mt-auto {\n margin-top: auto !important;\n}\n\n.me-0 {\n margin-right: 0 !important;\n}\n\n.me-1 {\n margin-right: 0.25rem !important;\n}\n\n.me-2 {\n margin-right: 0.5rem !important;\n}\n\n.me-3 {\n margin-right: 1rem !important;\n}\n\n.me-4 {\n margin-right: 1.5rem !important;\n}\n\n.me-5 {\n margin-right: 3rem !important;\n}\n\n.me-auto {\n margin-right: auto !important;\n}\n\n.mb-0 {\n margin-bottom: 0 !important;\n}\n\n.mb-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.mb-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.mb-3 {\n margin-bottom: 1rem !important;\n}\n\n.mb-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.mb-5 {\n margin-bottom: 3rem !important;\n}\n\n.mb-auto {\n margin-bottom: auto !important;\n}\n\n.ms-0 {\n margin-left: 0 !important;\n}\n\n.ms-1 {\n margin-left: 0.25rem !important;\n}\n\n.ms-2 {\n margin-left: 0.5rem !important;\n}\n\n.ms-3 {\n margin-left: 1rem !important;\n}\n\n.ms-4 {\n margin-left: 1.5rem !important;\n}\n\n.ms-5 {\n margin-left: 3rem !important;\n}\n\n.ms-auto {\n margin-left: auto !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.px-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n}\n\n.px-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n}\n\n.px-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n}\n\n.px-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n}\n\n.px-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n}\n\n.px-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n}\n\n.py-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n}\n\n.py-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n}\n\n.py-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n}\n\n.py-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n}\n\n.py-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n}\n\n.py-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n}\n\n.pt-0 {\n padding-top: 0 !important;\n}\n\n.pt-1 {\n padding-top: 0.25rem !important;\n}\n\n.pt-2 {\n padding-top: 0.5rem !important;\n}\n\n.pt-3 {\n padding-top: 1rem !important;\n}\n\n.pt-4 {\n padding-top: 1.5rem !important;\n}\n\n.pt-5 {\n padding-top: 3rem !important;\n}\n\n.pe-0 {\n padding-right: 0 !important;\n}\n\n.pe-1 {\n padding-right: 0.25rem !important;\n}\n\n.pe-2 {\n padding-right: 0.5rem !important;\n}\n\n.pe-3 {\n padding-right: 1rem !important;\n}\n\n.pe-4 {\n padding-right: 1.5rem !important;\n}\n\n.pe-5 {\n padding-right: 3rem !important;\n}\n\n.pb-0 {\n padding-bottom: 0 !important;\n}\n\n.pb-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pb-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pb-3 {\n padding-bottom: 1rem !important;\n}\n\n.pb-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pb-5 {\n padding-bottom: 3rem !important;\n}\n\n.ps-0 {\n padding-left: 0 !important;\n}\n\n.ps-1 {\n padding-left: 0.25rem !important;\n}\n\n.ps-2 {\n padding-left: 0.5rem !important;\n}\n\n.ps-3 {\n padding-left: 1rem !important;\n}\n\n.ps-4 {\n padding-left: 1.5rem !important;\n}\n\n.ps-5 {\n padding-left: 3rem !important;\n}\n\n@media (min-width: 576px) {\n .d-sm-inline {\n display: inline !important;\n }\n .d-sm-inline-block {\n display: inline-block !important;\n }\n .d-sm-block {\n display: block !important;\n }\n .d-sm-grid {\n display: grid !important;\n }\n .d-sm-inline-grid {\n display: inline-grid !important;\n }\n .d-sm-table {\n display: table !important;\n }\n .d-sm-table-row {\n display: table-row !important;\n }\n .d-sm-table-cell {\n display: table-cell !important;\n }\n .d-sm-flex {\n display: flex !important;\n }\n .d-sm-inline-flex {\n display: inline-flex !important;\n }\n .d-sm-none {\n display: none !important;\n }\n .flex-sm-fill {\n flex: 1 1 auto !important;\n }\n .flex-sm-row {\n flex-direction: row !important;\n }\n .flex-sm-column {\n flex-direction: column !important;\n }\n .flex-sm-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-sm-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-sm-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-sm-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-sm-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-sm-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-sm-wrap {\n flex-wrap: wrap !important;\n }\n .flex-sm-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-sm-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-sm-start {\n justify-content: flex-start !important;\n }\n .justify-content-sm-end {\n justify-content: flex-end !important;\n }\n .justify-content-sm-center {\n justify-content: center !important;\n }\n .justify-content-sm-between {\n justify-content: space-between !important;\n }\n .justify-content-sm-around {\n justify-content: space-around !important;\n }\n .justify-content-sm-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-sm-start {\n align-items: flex-start !important;\n }\n .align-items-sm-end {\n align-items: flex-end !important;\n }\n .align-items-sm-center {\n align-items: center !important;\n }\n .align-items-sm-baseline {\n align-items: baseline !important;\n }\n .align-items-sm-stretch {\n align-items: stretch !important;\n }\n .align-content-sm-start {\n align-content: flex-start !important;\n }\n .align-content-sm-end {\n align-content: flex-end !important;\n }\n .align-content-sm-center {\n align-content: center !important;\n }\n .align-content-sm-between {\n align-content: space-between !important;\n }\n .align-content-sm-around {\n align-content: space-around !important;\n }\n .align-content-sm-stretch {\n align-content: stretch !important;\n }\n .align-self-sm-auto {\n align-self: auto !important;\n }\n .align-self-sm-start {\n align-self: flex-start !important;\n }\n .align-self-sm-end {\n align-self: flex-end !important;\n }\n .align-self-sm-center {\n align-self: center !important;\n }\n .align-self-sm-baseline {\n align-self: baseline !important;\n }\n .align-self-sm-stretch {\n align-self: stretch !important;\n }\n .order-sm-first {\n order: -1 !important;\n }\n .order-sm-0 {\n order: 0 !important;\n }\n .order-sm-1 {\n order: 1 !important;\n }\n .order-sm-2 {\n order: 2 !important;\n }\n .order-sm-3 {\n order: 3 !important;\n }\n .order-sm-4 {\n order: 4 !important;\n }\n .order-sm-5 {\n order: 5 !important;\n }\n .order-sm-last {\n order: 6 !important;\n }\n .m-sm-0 {\n margin: 0 !important;\n }\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n .m-sm-3 {\n margin: 1rem !important;\n }\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n .m-sm-5 {\n margin: 3rem !important;\n }\n .m-sm-auto {\n margin: auto !important;\n }\n .mx-sm-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-sm-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-sm-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-sm-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-sm-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-sm-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-sm-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-sm-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-sm-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-sm-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-sm-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-sm-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-sm-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-sm-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-sm-0 {\n margin-top: 0 !important;\n }\n .mt-sm-1 {\n margin-top: 0.25rem !important;\n }\n .mt-sm-2 {\n margin-top: 0.5rem !important;\n }\n .mt-sm-3 {\n margin-top: 1rem !important;\n }\n .mt-sm-4 {\n margin-top: 1.5rem !important;\n }\n .mt-sm-5 {\n margin-top: 3rem !important;\n }\n .mt-sm-auto {\n margin-top: auto !important;\n }\n .me-sm-0 {\n margin-right: 0 !important;\n }\n .me-sm-1 {\n margin-right: 0.25rem !important;\n }\n .me-sm-2 {\n margin-right: 0.5rem !important;\n }\n .me-sm-3 {\n margin-right: 1rem !important;\n }\n .me-sm-4 {\n margin-right: 1.5rem !important;\n }\n .me-sm-5 {\n margin-right: 3rem !important;\n }\n .me-sm-auto {\n margin-right: auto !important;\n }\n .mb-sm-0 {\n margin-bottom: 0 !important;\n }\n .mb-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-sm-3 {\n margin-bottom: 1rem !important;\n }\n .mb-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-sm-5 {\n margin-bottom: 3rem !important;\n }\n .mb-sm-auto {\n margin-bottom: auto !important;\n }\n .ms-sm-0 {\n margin-left: 0 !important;\n }\n .ms-sm-1 {\n margin-left: 0.25rem !important;\n }\n .ms-sm-2 {\n margin-left: 0.5rem !important;\n }\n .ms-sm-3 {\n margin-left: 1rem !important;\n }\n .ms-sm-4 {\n margin-left: 1.5rem !important;\n }\n .ms-sm-5 {\n margin-left: 3rem !important;\n }\n .ms-sm-auto {\n margin-left: auto !important;\n }\n .p-sm-0 {\n padding: 0 !important;\n }\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n .p-sm-3 {\n padding: 1rem !important;\n }\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n .p-sm-5 {\n padding: 3rem !important;\n }\n .px-sm-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-sm-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-sm-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-sm-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-sm-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-sm-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-sm-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-sm-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-sm-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-sm-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-sm-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-sm-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-sm-0 {\n padding-top: 0 !important;\n }\n .pt-sm-1 {\n padding-top: 0.25rem !important;\n }\n .pt-sm-2 {\n padding-top: 0.5rem !important;\n }\n .pt-sm-3 {\n padding-top: 1rem !important;\n }\n .pt-sm-4 {\n padding-top: 1.5rem !important;\n }\n .pt-sm-5 {\n padding-top: 3rem !important;\n }\n .pe-sm-0 {\n padding-right: 0 !important;\n }\n .pe-sm-1 {\n padding-right: 0.25rem !important;\n }\n .pe-sm-2 {\n padding-right: 0.5rem !important;\n }\n .pe-sm-3 {\n padding-right: 1rem !important;\n }\n .pe-sm-4 {\n padding-right: 1.5rem !important;\n }\n .pe-sm-5 {\n padding-right: 3rem !important;\n }\n .pb-sm-0 {\n padding-bottom: 0 !important;\n }\n .pb-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-sm-3 {\n padding-bottom: 1rem !important;\n }\n .pb-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-sm-5 {\n padding-bottom: 3rem !important;\n }\n .ps-sm-0 {\n padding-left: 0 !important;\n }\n .ps-sm-1 {\n padding-left: 0.25rem !important;\n }\n .ps-sm-2 {\n padding-left: 0.5rem !important;\n }\n .ps-sm-3 {\n padding-left: 1rem !important;\n }\n .ps-sm-4 {\n padding-left: 1.5rem !important;\n }\n .ps-sm-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 768px) {\n .d-md-inline {\n display: inline !important;\n }\n .d-md-inline-block {\n display: inline-block !important;\n }\n .d-md-block {\n display: block !important;\n }\n .d-md-grid {\n display: grid !important;\n }\n .d-md-inline-grid {\n display: inline-grid !important;\n }\n .d-md-table {\n display: table !important;\n }\n .d-md-table-row {\n display: table-row !important;\n }\n .d-md-table-cell {\n display: table-cell !important;\n }\n .d-md-flex {\n display: flex !important;\n }\n .d-md-inline-flex {\n display: inline-flex !important;\n }\n .d-md-none {\n display: none !important;\n }\n .flex-md-fill {\n flex: 1 1 auto !important;\n }\n .flex-md-row {\n flex-direction: row !important;\n }\n .flex-md-column {\n flex-direction: column !important;\n }\n .flex-md-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-md-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-md-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-md-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-md-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-md-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-md-wrap {\n flex-wrap: wrap !important;\n }\n .flex-md-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-md-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-md-start {\n justify-content: flex-start !important;\n }\n .justify-content-md-end {\n justify-content: flex-end !important;\n }\n .justify-content-md-center {\n justify-content: center !important;\n }\n .justify-content-md-between {\n justify-content: space-between !important;\n }\n .justify-content-md-around {\n justify-content: space-around !important;\n }\n .justify-content-md-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-md-start {\n align-items: flex-start !important;\n }\n .align-items-md-end {\n align-items: flex-end !important;\n }\n .align-items-md-center {\n align-items: center !important;\n }\n .align-items-md-baseline {\n align-items: baseline !important;\n }\n .align-items-md-stretch {\n align-items: stretch !important;\n }\n .align-content-md-start {\n align-content: flex-start !important;\n }\n .align-content-md-end {\n align-content: flex-end !important;\n }\n .align-content-md-center {\n align-content: center !important;\n }\n .align-content-md-between {\n align-content: space-between !important;\n }\n .align-content-md-around {\n align-content: space-around !important;\n }\n .align-content-md-stretch {\n align-content: stretch !important;\n }\n .align-self-md-auto {\n align-self: auto !important;\n }\n .align-self-md-start {\n align-self: flex-start !important;\n }\n .align-self-md-end {\n align-self: flex-end !important;\n }\n .align-self-md-center {\n align-self: center !important;\n }\n .align-self-md-baseline {\n align-self: baseline !important;\n }\n .align-self-md-stretch {\n align-self: stretch !important;\n }\n .order-md-first {\n order: -1 !important;\n }\n .order-md-0 {\n order: 0 !important;\n }\n .order-md-1 {\n order: 1 !important;\n }\n .order-md-2 {\n order: 2 !important;\n }\n .order-md-3 {\n order: 3 !important;\n }\n .order-md-4 {\n order: 4 !important;\n }\n .order-md-5 {\n order: 5 !important;\n }\n .order-md-last {\n order: 6 !important;\n }\n .m-md-0 {\n margin: 0 !important;\n }\n .m-md-1 {\n margin: 0.25rem !important;\n }\n .m-md-2 {\n margin: 0.5rem !important;\n }\n .m-md-3 {\n margin: 1rem !important;\n }\n .m-md-4 {\n margin: 1.5rem !important;\n }\n .m-md-5 {\n margin: 3rem !important;\n }\n .m-md-auto {\n margin: auto !important;\n }\n .mx-md-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-md-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-md-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-md-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-md-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-md-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-md-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-md-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-md-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-md-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-md-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-md-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-md-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-md-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-md-0 {\n margin-top: 0 !important;\n }\n .mt-md-1 {\n margin-top: 0.25rem !important;\n }\n .mt-md-2 {\n margin-top: 0.5rem !important;\n }\n .mt-md-3 {\n margin-top: 1rem !important;\n }\n .mt-md-4 {\n margin-top: 1.5rem !important;\n }\n .mt-md-5 {\n margin-top: 3rem !important;\n }\n .mt-md-auto {\n margin-top: auto !important;\n }\n .me-md-0 {\n margin-right: 0 !important;\n }\n .me-md-1 {\n margin-right: 0.25rem !important;\n }\n .me-md-2 {\n margin-right: 0.5rem !important;\n }\n .me-md-3 {\n margin-right: 1rem !important;\n }\n .me-md-4 {\n margin-right: 1.5rem !important;\n }\n .me-md-5 {\n margin-right: 3rem !important;\n }\n .me-md-auto {\n margin-right: auto !important;\n }\n .mb-md-0 {\n margin-bottom: 0 !important;\n }\n .mb-md-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-md-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-md-3 {\n margin-bottom: 1rem !important;\n }\n .mb-md-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-md-5 {\n margin-bottom: 3rem !important;\n }\n .mb-md-auto {\n margin-bottom: auto !important;\n }\n .ms-md-0 {\n margin-left: 0 !important;\n }\n .ms-md-1 {\n margin-left: 0.25rem !important;\n }\n .ms-md-2 {\n margin-left: 0.5rem !important;\n }\n .ms-md-3 {\n margin-left: 1rem !important;\n }\n .ms-md-4 {\n margin-left: 1.5rem !important;\n }\n .ms-md-5 {\n margin-left: 3rem !important;\n }\n .ms-md-auto {\n margin-left: auto !important;\n }\n .p-md-0 {\n padding: 0 !important;\n }\n .p-md-1 {\n padding: 0.25rem !important;\n }\n .p-md-2 {\n padding: 0.5rem !important;\n }\n .p-md-3 {\n padding: 1rem !important;\n }\n .p-md-4 {\n padding: 1.5rem !important;\n }\n .p-md-5 {\n padding: 3rem !important;\n }\n .px-md-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-md-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-md-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-md-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-md-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-md-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-md-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-md-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-md-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-md-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-md-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-md-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-md-0 {\n padding-top: 0 !important;\n }\n .pt-md-1 {\n padding-top: 0.25rem !important;\n }\n .pt-md-2 {\n padding-top: 0.5rem !important;\n }\n .pt-md-3 {\n padding-top: 1rem !important;\n }\n .pt-md-4 {\n padding-top: 1.5rem !important;\n }\n .pt-md-5 {\n padding-top: 3rem !important;\n }\n .pe-md-0 {\n padding-right: 0 !important;\n }\n .pe-md-1 {\n padding-right: 0.25rem !important;\n }\n .pe-md-2 {\n padding-right: 0.5rem !important;\n }\n .pe-md-3 {\n padding-right: 1rem !important;\n }\n .pe-md-4 {\n padding-right: 1.5rem !important;\n }\n .pe-md-5 {\n padding-right: 3rem !important;\n }\n .pb-md-0 {\n padding-bottom: 0 !important;\n }\n .pb-md-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-md-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-md-3 {\n padding-bottom: 1rem !important;\n }\n .pb-md-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-md-5 {\n padding-bottom: 3rem !important;\n }\n .ps-md-0 {\n padding-left: 0 !important;\n }\n .ps-md-1 {\n padding-left: 0.25rem !important;\n }\n .ps-md-2 {\n padding-left: 0.5rem !important;\n }\n .ps-md-3 {\n padding-left: 1rem !important;\n }\n .ps-md-4 {\n padding-left: 1.5rem !important;\n }\n .ps-md-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 992px) {\n .d-lg-inline {\n display: inline !important;\n }\n .d-lg-inline-block {\n display: inline-block !important;\n }\n .d-lg-block {\n display: block !important;\n }\n .d-lg-grid {\n display: grid !important;\n }\n .d-lg-inline-grid {\n display: inline-grid !important;\n }\n .d-lg-table {\n display: table !important;\n }\n .d-lg-table-row {\n display: table-row !important;\n }\n .d-lg-table-cell {\n display: table-cell !important;\n }\n .d-lg-flex {\n display: flex !important;\n }\n .d-lg-inline-flex {\n display: inline-flex !important;\n }\n .d-lg-none {\n display: none !important;\n }\n .flex-lg-fill {\n flex: 1 1 auto !important;\n }\n .flex-lg-row {\n flex-direction: row !important;\n }\n .flex-lg-column {\n flex-direction: column !important;\n }\n .flex-lg-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-lg-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-lg-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-lg-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-lg-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-lg-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-lg-wrap {\n flex-wrap: wrap !important;\n }\n .flex-lg-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-lg-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-lg-start {\n justify-content: flex-start !important;\n }\n .justify-content-lg-end {\n justify-content: flex-end !important;\n }\n .justify-content-lg-center {\n justify-content: center !important;\n }\n .justify-content-lg-between {\n justify-content: space-between !important;\n }\n .justify-content-lg-around {\n justify-content: space-around !important;\n }\n .justify-content-lg-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-lg-start {\n align-items: flex-start !important;\n }\n .align-items-lg-end {\n align-items: flex-end !important;\n }\n .align-items-lg-center {\n align-items: center !important;\n }\n .align-items-lg-baseline {\n align-items: baseline !important;\n }\n .align-items-lg-stretch {\n align-items: stretch !important;\n }\n .align-content-lg-start {\n align-content: flex-start !important;\n }\n .align-content-lg-end {\n align-content: flex-end !important;\n }\n .align-content-lg-center {\n align-content: center !important;\n }\n .align-content-lg-between {\n align-content: space-between !important;\n }\n .align-content-lg-around {\n align-content: space-around !important;\n }\n .align-content-lg-stretch {\n align-content: stretch !important;\n }\n .align-self-lg-auto {\n align-self: auto !important;\n }\n .align-self-lg-start {\n align-self: flex-start !important;\n }\n .align-self-lg-end {\n align-self: flex-end !important;\n }\n .align-self-lg-center {\n align-self: center !important;\n }\n .align-self-lg-baseline {\n align-self: baseline !important;\n }\n .align-self-lg-stretch {\n align-self: stretch !important;\n }\n .order-lg-first {\n order: -1 !important;\n }\n .order-lg-0 {\n order: 0 !important;\n }\n .order-lg-1 {\n order: 1 !important;\n }\n .order-lg-2 {\n order: 2 !important;\n }\n .order-lg-3 {\n order: 3 !important;\n }\n .order-lg-4 {\n order: 4 !important;\n }\n .order-lg-5 {\n order: 5 !important;\n }\n .order-lg-last {\n order: 6 !important;\n }\n .m-lg-0 {\n margin: 0 !important;\n }\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n .m-lg-3 {\n margin: 1rem !important;\n }\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n .m-lg-5 {\n margin: 3rem !important;\n }\n .m-lg-auto {\n margin: auto !important;\n }\n .mx-lg-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-lg-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-lg-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-lg-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-lg-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-lg-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-lg-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-lg-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-lg-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-lg-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-lg-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-lg-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-lg-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-lg-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-lg-0 {\n margin-top: 0 !important;\n }\n .mt-lg-1 {\n margin-top: 0.25rem !important;\n }\n .mt-lg-2 {\n margin-top: 0.5rem !important;\n }\n .mt-lg-3 {\n margin-top: 1rem !important;\n }\n .mt-lg-4 {\n margin-top: 1.5rem !important;\n }\n .mt-lg-5 {\n margin-top: 3rem !important;\n }\n .mt-lg-auto {\n margin-top: auto !important;\n }\n .me-lg-0 {\n margin-right: 0 !important;\n }\n .me-lg-1 {\n margin-right: 0.25rem !important;\n }\n .me-lg-2 {\n margin-right: 0.5rem !important;\n }\n .me-lg-3 {\n margin-right: 1rem !important;\n }\n .me-lg-4 {\n margin-right: 1.5rem !important;\n }\n .me-lg-5 {\n margin-right: 3rem !important;\n }\n .me-lg-auto {\n margin-right: auto !important;\n }\n .mb-lg-0 {\n margin-bottom: 0 !important;\n }\n .mb-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-lg-3 {\n margin-bottom: 1rem !important;\n }\n .mb-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-lg-5 {\n margin-bottom: 3rem !important;\n }\n .mb-lg-auto {\n margin-bottom: auto !important;\n }\n .ms-lg-0 {\n margin-left: 0 !important;\n }\n .ms-lg-1 {\n margin-left: 0.25rem !important;\n }\n .ms-lg-2 {\n margin-left: 0.5rem !important;\n }\n .ms-lg-3 {\n margin-left: 1rem !important;\n }\n .ms-lg-4 {\n margin-left: 1.5rem !important;\n }\n .ms-lg-5 {\n margin-left: 3rem !important;\n }\n .ms-lg-auto {\n margin-left: auto !important;\n }\n .p-lg-0 {\n padding: 0 !important;\n }\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n .p-lg-3 {\n padding: 1rem !important;\n }\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n .p-lg-5 {\n padding: 3rem !important;\n }\n .px-lg-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-lg-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-lg-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-lg-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-lg-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-lg-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-lg-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-lg-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-lg-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-lg-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-lg-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-lg-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-lg-0 {\n padding-top: 0 !important;\n }\n .pt-lg-1 {\n padding-top: 0.25rem !important;\n }\n .pt-lg-2 {\n padding-top: 0.5rem !important;\n }\n .pt-lg-3 {\n padding-top: 1rem !important;\n }\n .pt-lg-4 {\n padding-top: 1.5rem !important;\n }\n .pt-lg-5 {\n padding-top: 3rem !important;\n }\n .pe-lg-0 {\n padding-right: 0 !important;\n }\n .pe-lg-1 {\n padding-right: 0.25rem !important;\n }\n .pe-lg-2 {\n padding-right: 0.5rem !important;\n }\n .pe-lg-3 {\n padding-right: 1rem !important;\n }\n .pe-lg-4 {\n padding-right: 1.5rem !important;\n }\n .pe-lg-5 {\n padding-right: 3rem !important;\n }\n .pb-lg-0 {\n padding-bottom: 0 !important;\n }\n .pb-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-lg-3 {\n padding-bottom: 1rem !important;\n }\n .pb-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-lg-5 {\n padding-bottom: 3rem !important;\n }\n .ps-lg-0 {\n padding-left: 0 !important;\n }\n .ps-lg-1 {\n padding-left: 0.25rem !important;\n }\n .ps-lg-2 {\n padding-left: 0.5rem !important;\n }\n .ps-lg-3 {\n padding-left: 1rem !important;\n }\n .ps-lg-4 {\n padding-left: 1.5rem !important;\n }\n .ps-lg-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 1200px) {\n .d-xl-inline {\n display: inline !important;\n }\n .d-xl-inline-block {\n display: inline-block !important;\n }\n .d-xl-block {\n display: block !important;\n }\n .d-xl-grid {\n display: grid !important;\n }\n .d-xl-inline-grid {\n display: inline-grid !important;\n }\n .d-xl-table {\n display: table !important;\n }\n .d-xl-table-row {\n display: table-row !important;\n }\n .d-xl-table-cell {\n display: table-cell !important;\n }\n .d-xl-flex {\n display: flex !important;\n }\n .d-xl-inline-flex {\n display: inline-flex !important;\n }\n .d-xl-none {\n display: none !important;\n }\n .flex-xl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xl-row {\n flex-direction: row !important;\n }\n .flex-xl-column {\n flex-direction: column !important;\n }\n .flex-xl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xl-center {\n justify-content: center !important;\n }\n .justify-content-xl-between {\n justify-content: space-between !important;\n }\n .justify-content-xl-around {\n justify-content: space-around !important;\n }\n .justify-content-xl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xl-start {\n align-items: flex-start !important;\n }\n .align-items-xl-end {\n align-items: flex-end !important;\n }\n .align-items-xl-center {\n align-items: center !important;\n }\n .align-items-xl-baseline {\n align-items: baseline !important;\n }\n .align-items-xl-stretch {\n align-items: stretch !important;\n }\n .align-content-xl-start {\n align-content: flex-start !important;\n }\n .align-content-xl-end {\n align-content: flex-end !important;\n }\n .align-content-xl-center {\n align-content: center !important;\n }\n .align-content-xl-between {\n align-content: space-between !important;\n }\n .align-content-xl-around {\n align-content: space-around !important;\n }\n .align-content-xl-stretch {\n align-content: stretch !important;\n }\n .align-self-xl-auto {\n align-self: auto !important;\n }\n .align-self-xl-start {\n align-self: flex-start !important;\n }\n .align-self-xl-end {\n align-self: flex-end !important;\n }\n .align-self-xl-center {\n align-self: center !important;\n }\n .align-self-xl-baseline {\n align-self: baseline !important;\n }\n .align-self-xl-stretch {\n align-self: stretch !important;\n }\n .order-xl-first {\n order: -1 !important;\n }\n .order-xl-0 {\n order: 0 !important;\n }\n .order-xl-1 {\n order: 1 !important;\n }\n .order-xl-2 {\n order: 2 !important;\n }\n .order-xl-3 {\n order: 3 !important;\n }\n .order-xl-4 {\n order: 4 !important;\n }\n .order-xl-5 {\n order: 5 !important;\n }\n .order-xl-last {\n order: 6 !important;\n }\n .m-xl-0 {\n margin: 0 !important;\n }\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n .m-xl-3 {\n margin: 1rem !important;\n }\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n .m-xl-5 {\n margin: 3rem !important;\n }\n .m-xl-auto {\n margin: auto !important;\n }\n .mx-xl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xl-0 {\n margin-top: 0 !important;\n }\n .mt-xl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xl-3 {\n margin-top: 1rem !important;\n }\n .mt-xl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xl-5 {\n margin-top: 3rem !important;\n }\n .mt-xl-auto {\n margin-top: auto !important;\n }\n .me-xl-0 {\n margin-right: 0 !important;\n }\n .me-xl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xl-3 {\n margin-right: 1rem !important;\n }\n .me-xl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xl-5 {\n margin-right: 3rem !important;\n }\n .me-xl-auto {\n margin-right: auto !important;\n }\n .mb-xl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xl-auto {\n margin-bottom: auto !important;\n }\n .ms-xl-0 {\n margin-left: 0 !important;\n }\n .ms-xl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xl-3 {\n margin-left: 1rem !important;\n }\n .ms-xl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xl-5 {\n margin-left: 3rem !important;\n }\n .ms-xl-auto {\n margin-left: auto !important;\n }\n .p-xl-0 {\n padding: 0 !important;\n }\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n .p-xl-3 {\n padding: 1rem !important;\n }\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n .p-xl-5 {\n padding: 3rem !important;\n }\n .px-xl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xl-0 {\n padding-top: 0 !important;\n }\n .pt-xl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xl-3 {\n padding-top: 1rem !important;\n }\n .pt-xl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xl-5 {\n padding-top: 3rem !important;\n }\n .pe-xl-0 {\n padding-right: 0 !important;\n }\n .pe-xl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xl-3 {\n padding-right: 1rem !important;\n }\n .pe-xl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xl-5 {\n padding-right: 3rem !important;\n }\n .pb-xl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xl-0 {\n padding-left: 0 !important;\n }\n .ps-xl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xl-3 {\n padding-left: 1rem !important;\n }\n .ps-xl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xl-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 1400px) {\n .d-xxl-inline {\n display: inline !important;\n }\n .d-xxl-inline-block {\n display: inline-block !important;\n }\n .d-xxl-block {\n display: block !important;\n }\n .d-xxl-grid {\n display: grid !important;\n }\n .d-xxl-inline-grid {\n display: inline-grid !important;\n }\n .d-xxl-table {\n display: table !important;\n }\n .d-xxl-table-row {\n display: table-row !important;\n }\n .d-xxl-table-cell {\n display: table-cell !important;\n }\n .d-xxl-flex {\n display: flex !important;\n }\n .d-xxl-inline-flex {\n display: inline-flex !important;\n }\n .d-xxl-none {\n display: none !important;\n }\n .flex-xxl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xxl-row {\n flex-direction: row !important;\n }\n .flex-xxl-column {\n flex-direction: column !important;\n }\n .flex-xxl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xxl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xxl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xxl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xxl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xxl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xxl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xxl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xxl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xxl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xxl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xxl-center {\n justify-content: center !important;\n }\n .justify-content-xxl-between {\n justify-content: space-between !important;\n }\n .justify-content-xxl-around {\n justify-content: space-around !important;\n }\n .justify-content-xxl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xxl-start {\n align-items: flex-start !important;\n }\n .align-items-xxl-end {\n align-items: flex-end !important;\n }\n .align-items-xxl-center {\n align-items: center !important;\n }\n .align-items-xxl-baseline {\n align-items: baseline !important;\n }\n .align-items-xxl-stretch {\n align-items: stretch !important;\n }\n .align-content-xxl-start {\n align-content: flex-start !important;\n }\n .align-content-xxl-end {\n align-content: flex-end !important;\n }\n .align-content-xxl-center {\n align-content: center !important;\n }\n .align-content-xxl-between {\n align-content: space-between !important;\n }\n .align-content-xxl-around {\n align-content: space-around !important;\n }\n .align-content-xxl-stretch {\n align-content: stretch !important;\n }\n .align-self-xxl-auto {\n align-self: auto !important;\n }\n .align-self-xxl-start {\n align-self: flex-start !important;\n }\n .align-self-xxl-end {\n align-self: flex-end !important;\n }\n .align-self-xxl-center {\n align-self: center !important;\n }\n .align-self-xxl-baseline {\n align-self: baseline !important;\n }\n .align-self-xxl-stretch {\n align-self: stretch !important;\n }\n .order-xxl-first {\n order: -1 !important;\n }\n .order-xxl-0 {\n order: 0 !important;\n }\n .order-xxl-1 {\n order: 1 !important;\n }\n .order-xxl-2 {\n order: 2 !important;\n }\n .order-xxl-3 {\n order: 3 !important;\n }\n .order-xxl-4 {\n order: 4 !important;\n }\n .order-xxl-5 {\n order: 5 !important;\n }\n .order-xxl-last {\n order: 6 !important;\n }\n .m-xxl-0 {\n margin: 0 !important;\n }\n .m-xxl-1 {\n margin: 0.25rem !important;\n }\n .m-xxl-2 {\n margin: 0.5rem !important;\n }\n .m-xxl-3 {\n margin: 1rem !important;\n }\n .m-xxl-4 {\n margin: 1.5rem !important;\n }\n .m-xxl-5 {\n margin: 3rem !important;\n }\n .m-xxl-auto {\n margin: auto !important;\n }\n .mx-xxl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xxl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xxl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xxl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xxl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xxl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xxl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xxl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xxl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xxl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xxl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xxl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xxl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xxl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xxl-0 {\n margin-top: 0 !important;\n }\n .mt-xxl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xxl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xxl-3 {\n margin-top: 1rem !important;\n }\n .mt-xxl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xxl-5 {\n margin-top: 3rem !important;\n }\n .mt-xxl-auto {\n margin-top: auto !important;\n }\n .me-xxl-0 {\n margin-right: 0 !important;\n }\n .me-xxl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xxl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xxl-3 {\n margin-right: 1rem !important;\n }\n .me-xxl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xxl-5 {\n margin-right: 3rem !important;\n }\n .me-xxl-auto {\n margin-right: auto !important;\n }\n .mb-xxl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xxl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xxl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xxl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xxl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xxl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xxl-auto {\n margin-bottom: auto !important;\n }\n .ms-xxl-0 {\n margin-left: 0 !important;\n }\n .ms-xxl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xxl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xxl-3 {\n margin-left: 1rem !important;\n }\n .ms-xxl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xxl-5 {\n margin-left: 3rem !important;\n }\n .ms-xxl-auto {\n margin-left: auto !important;\n }\n .p-xxl-0 {\n padding: 0 !important;\n }\n .p-xxl-1 {\n padding: 0.25rem !important;\n }\n .p-xxl-2 {\n padding: 0.5rem !important;\n }\n .p-xxl-3 {\n padding: 1rem !important;\n }\n .p-xxl-4 {\n padding: 1.5rem !important;\n }\n .p-xxl-5 {\n padding: 3rem !important;\n }\n .px-xxl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xxl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xxl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xxl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xxl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xxl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xxl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xxl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xxl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xxl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xxl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xxl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xxl-0 {\n padding-top: 0 !important;\n }\n .pt-xxl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xxl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xxl-3 {\n padding-top: 1rem !important;\n }\n .pt-xxl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xxl-5 {\n padding-top: 3rem !important;\n }\n .pe-xxl-0 {\n padding-right: 0 !important;\n }\n .pe-xxl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xxl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xxl-3 {\n padding-right: 1rem !important;\n }\n .pe-xxl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xxl-5 {\n padding-right: 3rem !important;\n }\n .pb-xxl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xxl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xxl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xxl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xxl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xxl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xxl-0 {\n padding-left: 0 !important;\n }\n .ps-xxl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xxl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xxl-3 {\n padding-left: 1rem !important;\n }\n .ps-xxl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xxl-5 {\n padding-left: 3rem !important;\n }\n}\n@media print {\n .d-print-inline {\n display: inline !important;\n }\n .d-print-inline-block {\n display: inline-block !important;\n }\n .d-print-block {\n display: block !important;\n }\n .d-print-grid {\n display: grid !important;\n }\n .d-print-inline-grid {\n display: inline-grid !important;\n }\n .d-print-table {\n display: table !important;\n }\n .d-print-table-row {\n display: table-row !important;\n }\n .d-print-table-cell {\n display: table-cell !important;\n }\n .d-print-flex {\n display: flex !important;\n }\n .d-print-inline-flex {\n display: inline-flex !important;\n }\n .d-print-none {\n display: none !important;\n }\n}\n\n/*# sourceMappingURL=bootstrap-grid.css.map */\n","// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)\n//\n// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n// >> breakpoint-next(sm)\n// md\n// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// md\n// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl))\n// md\n@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {\n $n: index($breakpoint-names, $name);\n @if not $n {\n @error \"breakpoint `#{$name}` not found in `#{$breakpoints}`\";\n }\n @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 576px\n@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {\n $min: map-get($breakpoints, $name);\n @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width.\n// The maximum value is reduced by 0.02px to work around the limitations of\n// `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 767.98px\n@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {\n $max: map-get($breakpoints, $name);\n @return if($max and $max > 0, $max - .02, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.\n// Useful for making responsive utilities.\n//\n// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"\" (Returns a blank string)\n// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"-sm\"\n@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {\n @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n @if $min {\n @media (min-width: $min) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {\n $max: breakpoint-max($name, $breakpoints);\n @if $max {\n @media (max-width: $max) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($lower, $breakpoints);\n $max: breakpoint-max($upper, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($lower, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($upper, $breakpoints) {\n @content;\n }\n }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n $next: breakpoint-next($name, $breakpoints);\n $max: breakpoint-max($next, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($name, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($next, $breakpoints) {\n @content;\n }\n }\n}\n","// Variables\n//\n// Variables should follow the `$component-state-property-size` formula for\n// consistent naming. Ex: $nav-link-disabled-color and $modal-content-box-shadow-xs.\n\n// Color system\n\n// scss-docs-start gray-color-variables\n$white: #fff !default;\n$gray-100: #f8f9fa !default;\n$gray-200: #e9ecef !default;\n$gray-300: #dee2e6 !default;\n$gray-400: #ced4da !default;\n$gray-500: #adb5bd !default;\n$gray-600: #6c757d !default;\n$gray-700: #495057 !default;\n$gray-800: #343a40 !default;\n$gray-900: #212529 !default;\n$black: #000 !default;\n// scss-docs-end gray-color-variables\n\n// fusv-disable\n// scss-docs-start gray-colors-map\n$grays: (\n \"100\": $gray-100,\n \"200\": $gray-200,\n \"300\": $gray-300,\n \"400\": $gray-400,\n \"500\": $gray-500,\n \"600\": $gray-600,\n \"700\": $gray-700,\n \"800\": $gray-800,\n \"900\": $gray-900\n) !default;\n// scss-docs-end gray-colors-map\n// fusv-enable\n\n// scss-docs-start color-variables\n$blue: #0d6efd !default;\n$indigo: #6610f2 !default;\n$purple: #6f42c1 !default;\n$pink: #d63384 !default;\n$red: #dc3545 !default;\n$orange: #fd7e14 !default;\n$yellow: #ffc107 !default;\n$green: #198754 !default;\n$teal: #20c997 !default;\n$cyan: #0dcaf0 !default;\n// scss-docs-end color-variables\n\n// scss-docs-start colors-map\n$colors: (\n \"blue\": $blue,\n \"indigo\": $indigo,\n \"purple\": $purple,\n \"pink\": $pink,\n \"red\": $red,\n \"orange\": $orange,\n \"yellow\": $yellow,\n \"green\": $green,\n \"teal\": $teal,\n \"cyan\": $cyan,\n \"black\": $black,\n \"white\": $white,\n \"gray\": $gray-600,\n \"gray-dark\": $gray-800\n) !default;\n// scss-docs-end colors-map\n\n// The contrast ratio to reach against white, to determine if color changes from \"light\" to \"dark\". Acceptable values for WCAG 2.0 are 3, 4.5 and 7.\n// See https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast\n$min-contrast-ratio: 4.5 !default;\n\n// Customize the light and dark text colors for use in our color contrast function.\n$color-contrast-dark: $black !default;\n$color-contrast-light: $white !default;\n\n// fusv-disable\n$blue-100: tint-color($blue, 80%) !default;\n$blue-200: tint-color($blue, 60%) !default;\n$blue-300: tint-color($blue, 40%) !default;\n$blue-400: tint-color($blue, 20%) !default;\n$blue-500: $blue !default;\n$blue-600: shade-color($blue, 20%) !default;\n$blue-700: shade-color($blue, 40%) !default;\n$blue-800: shade-color($blue, 60%) !default;\n$blue-900: shade-color($blue, 80%) !default;\n\n$indigo-100: tint-color($indigo, 80%) !default;\n$indigo-200: tint-color($indigo, 60%) !default;\n$indigo-300: tint-color($indigo, 40%) !default;\n$indigo-400: tint-color($indigo, 20%) !default;\n$indigo-500: $indigo !default;\n$indigo-600: shade-color($indigo, 20%) !default;\n$indigo-700: shade-color($indigo, 40%) !default;\n$indigo-800: shade-color($indigo, 60%) !default;\n$indigo-900: shade-color($indigo, 80%) !default;\n\n$purple-100: tint-color($purple, 80%) !default;\n$purple-200: tint-color($purple, 60%) !default;\n$purple-300: tint-color($purple, 40%) !default;\n$purple-400: tint-color($purple, 20%) !default;\n$purple-500: $purple !default;\n$purple-600: shade-color($purple, 20%) !default;\n$purple-700: shade-color($purple, 40%) !default;\n$purple-800: shade-color($purple, 60%) !default;\n$purple-900: shade-color($purple, 80%) !default;\n\n$pink-100: tint-color($pink, 80%) !default;\n$pink-200: tint-color($pink, 60%) !default;\n$pink-300: tint-color($pink, 40%) !default;\n$pink-400: tint-color($pink, 20%) !default;\n$pink-500: $pink !default;\n$pink-600: shade-color($pink, 20%) !default;\n$pink-700: shade-color($pink, 40%) !default;\n$pink-800: shade-color($pink, 60%) !default;\n$pink-900: shade-color($pink, 80%) !default;\n\n$red-100: tint-color($red, 80%) !default;\n$red-200: tint-color($red, 60%) !default;\n$red-300: tint-color($red, 40%) !default;\n$red-400: tint-color($red, 20%) !default;\n$red-500: $red !default;\n$red-600: shade-color($red, 20%) !default;\n$red-700: shade-color($red, 40%) !default;\n$red-800: shade-color($red, 60%) !default;\n$red-900: shade-color($red, 80%) !default;\n\n$orange-100: tint-color($orange, 80%) !default;\n$orange-200: tint-color($orange, 60%) !default;\n$orange-300: tint-color($orange, 40%) !default;\n$orange-400: tint-color($orange, 20%) !default;\n$orange-500: $orange !default;\n$orange-600: shade-color($orange, 20%) !default;\n$orange-700: shade-color($orange, 40%) !default;\n$orange-800: shade-color($orange, 60%) !default;\n$orange-900: shade-color($orange, 80%) !default;\n\n$yellow-100: tint-color($yellow, 80%) !default;\n$yellow-200: tint-color($yellow, 60%) !default;\n$yellow-300: tint-color($yellow, 40%) !default;\n$yellow-400: tint-color($yellow, 20%) !default;\n$yellow-500: $yellow !default;\n$yellow-600: shade-color($yellow, 20%) !default;\n$yellow-700: shade-color($yellow, 40%) !default;\n$yellow-800: shade-color($yellow, 60%) !default;\n$yellow-900: shade-color($yellow, 80%) !default;\n\n$green-100: tint-color($green, 80%) !default;\n$green-200: tint-color($green, 60%) !default;\n$green-300: tint-color($green, 40%) !default;\n$green-400: tint-color($green, 20%) !default;\n$green-500: $green !default;\n$green-600: shade-color($green, 20%) !default;\n$green-700: shade-color($green, 40%) !default;\n$green-800: shade-color($green, 60%) !default;\n$green-900: shade-color($green, 80%) !default;\n\n$teal-100: tint-color($teal, 80%) !default;\n$teal-200: tint-color($teal, 60%) !default;\n$teal-300: tint-color($teal, 40%) !default;\n$teal-400: tint-color($teal, 20%) !default;\n$teal-500: $teal !default;\n$teal-600: shade-color($teal, 20%) !default;\n$teal-700: shade-color($teal, 40%) !default;\n$teal-800: shade-color($teal, 60%) !default;\n$teal-900: shade-color($teal, 80%) !default;\n\n$cyan-100: tint-color($cyan, 80%) !default;\n$cyan-200: tint-color($cyan, 60%) !default;\n$cyan-300: tint-color($cyan, 40%) !default;\n$cyan-400: tint-color($cyan, 20%) !default;\n$cyan-500: $cyan !default;\n$cyan-600: shade-color($cyan, 20%) !default;\n$cyan-700: shade-color($cyan, 40%) !default;\n$cyan-800: shade-color($cyan, 60%) !default;\n$cyan-900: shade-color($cyan, 80%) !default;\n\n$blues: (\n \"blue-100\": $blue-100,\n \"blue-200\": $blue-200,\n \"blue-300\": $blue-300,\n \"blue-400\": $blue-400,\n \"blue-500\": $blue-500,\n \"blue-600\": $blue-600,\n \"blue-700\": $blue-700,\n \"blue-800\": $blue-800,\n \"blue-900\": $blue-900\n) !default;\n\n$indigos: (\n \"indigo-100\": $indigo-100,\n \"indigo-200\": $indigo-200,\n \"indigo-300\": $indigo-300,\n \"indigo-400\": $indigo-400,\n \"indigo-500\": $indigo-500,\n \"indigo-600\": $indigo-600,\n \"indigo-700\": $indigo-700,\n \"indigo-800\": $indigo-800,\n \"indigo-900\": $indigo-900\n) !default;\n\n$purples: (\n \"purple-100\": $purple-100,\n \"purple-200\": $purple-200,\n \"purple-300\": $purple-300,\n \"purple-400\": $purple-400,\n \"purple-500\": $purple-500,\n \"purple-600\": $purple-600,\n \"purple-700\": $purple-700,\n \"purple-800\": $purple-800,\n \"purple-900\": $purple-900\n) !default;\n\n$pinks: (\n \"pink-100\": $pink-100,\n \"pink-200\": $pink-200,\n \"pink-300\": $pink-300,\n \"pink-400\": $pink-400,\n \"pink-500\": $pink-500,\n \"pink-600\": $pink-600,\n \"pink-700\": $pink-700,\n \"pink-800\": $pink-800,\n \"pink-900\": $pink-900\n) !default;\n\n$reds: (\n \"red-100\": $red-100,\n \"red-200\": $red-200,\n \"red-300\": $red-300,\n \"red-400\": $red-400,\n \"red-500\": $red-500,\n \"red-600\": $red-600,\n \"red-700\": $red-700,\n \"red-800\": $red-800,\n \"red-900\": $red-900\n) !default;\n\n$oranges: (\n \"orange-100\": $orange-100,\n \"orange-200\": $orange-200,\n \"orange-300\": $orange-300,\n \"orange-400\": $orange-400,\n \"orange-500\": $orange-500,\n \"orange-600\": $orange-600,\n \"orange-700\": $orange-700,\n \"orange-800\": $orange-800,\n \"orange-900\": $orange-900\n) !default;\n\n$yellows: (\n \"yellow-100\": $yellow-100,\n \"yellow-200\": $yellow-200,\n \"yellow-300\": $yellow-300,\n \"yellow-400\": $yellow-400,\n \"yellow-500\": $yellow-500,\n \"yellow-600\": $yellow-600,\n \"yellow-700\": $yellow-700,\n \"yellow-800\": $yellow-800,\n \"yellow-900\": $yellow-900\n) !default;\n\n$greens: (\n \"green-100\": $green-100,\n \"green-200\": $green-200,\n \"green-300\": $green-300,\n \"green-400\": $green-400,\n \"green-500\": $green-500,\n \"green-600\": $green-600,\n \"green-700\": $green-700,\n \"green-800\": $green-800,\n \"green-900\": $green-900\n) !default;\n\n$teals: (\n \"teal-100\": $teal-100,\n \"teal-200\": $teal-200,\n \"teal-300\": $teal-300,\n \"teal-400\": $teal-400,\n \"teal-500\": $teal-500,\n \"teal-600\": $teal-600,\n \"teal-700\": $teal-700,\n \"teal-800\": $teal-800,\n \"teal-900\": $teal-900\n) !default;\n\n$cyans: (\n \"cyan-100\": $cyan-100,\n \"cyan-200\": $cyan-200,\n \"cyan-300\": $cyan-300,\n \"cyan-400\": $cyan-400,\n \"cyan-500\": $cyan-500,\n \"cyan-600\": $cyan-600,\n \"cyan-700\": $cyan-700,\n \"cyan-800\": $cyan-800,\n \"cyan-900\": $cyan-900\n) !default;\n// fusv-enable\n\n// scss-docs-start theme-color-variables\n$primary: $blue !default;\n$secondary: $gray-600 !default;\n$success: $green !default;\n$info: $cyan !default;\n$warning: $yellow !default;\n$danger: $red !default;\n$light: $gray-100 !default;\n$dark: $gray-900 !default;\n// scss-docs-end theme-color-variables\n\n// scss-docs-start theme-colors-map\n$theme-colors: (\n \"primary\": $primary,\n \"secondary\": $secondary,\n \"success\": $success,\n \"info\": $info,\n \"warning\": $warning,\n \"danger\": $danger,\n \"light\": $light,\n \"dark\": $dark\n) !default;\n// scss-docs-end theme-colors-map\n\n// scss-docs-start theme-text-variables\n$primary-text-emphasis: shade-color($primary, 60%) !default;\n$secondary-text-emphasis: shade-color($secondary, 60%) !default;\n$success-text-emphasis: shade-color($success, 60%) !default;\n$info-text-emphasis: shade-color($info, 60%) !default;\n$warning-text-emphasis: shade-color($warning, 60%) !default;\n$danger-text-emphasis: shade-color($danger, 60%) !default;\n$light-text-emphasis: $gray-700 !default;\n$dark-text-emphasis: $gray-700 !default;\n// scss-docs-end theme-text-variables\n\n// scss-docs-start theme-bg-subtle-variables\n$primary-bg-subtle: tint-color($primary, 80%) !default;\n$secondary-bg-subtle: tint-color($secondary, 80%) !default;\n$success-bg-subtle: tint-color($success, 80%) !default;\n$info-bg-subtle: tint-color($info, 80%) !default;\n$warning-bg-subtle: tint-color($warning, 80%) !default;\n$danger-bg-subtle: tint-color($danger, 80%) !default;\n$light-bg-subtle: mix($gray-100, $white) !default;\n$dark-bg-subtle: $gray-400 !default;\n// scss-docs-end theme-bg-subtle-variables\n\n// scss-docs-start theme-border-subtle-variables\n$primary-border-subtle: tint-color($primary, 60%) !default;\n$secondary-border-subtle: tint-color($secondary, 60%) !default;\n$success-border-subtle: tint-color($success, 60%) !default;\n$info-border-subtle: tint-color($info, 60%) !default;\n$warning-border-subtle: tint-color($warning, 60%) !default;\n$danger-border-subtle: tint-color($danger, 60%) !default;\n$light-border-subtle: $gray-200 !default;\n$dark-border-subtle: $gray-500 !default;\n// scss-docs-end theme-border-subtle-variables\n\n// Characters which are escaped by the escape-svg function\n$escaped-characters: (\n (\"<\", \"%3c\"),\n (\">\", \"%3e\"),\n (\"#\", \"%23\"),\n (\"(\", \"%28\"),\n (\")\", \"%29\"),\n) !default;\n\n// Options\n//\n// Quickly modify global styling by enabling or disabling optional features.\n\n$enable-caret: true !default;\n$enable-rounded: true !default;\n$enable-shadows: false !default;\n$enable-gradients: false !default;\n$enable-transitions: true !default;\n$enable-reduced-motion: true !default;\n$enable-smooth-scroll: true !default;\n$enable-grid-classes: true !default;\n$enable-container-classes: true !default;\n$enable-cssgrid: false !default;\n$enable-button-pointers: true !default;\n$enable-rfs: true !default;\n$enable-validation-icons: true !default;\n$enable-negative-margins: false !default;\n$enable-deprecation-messages: true !default;\n$enable-important-utilities: true !default;\n\n$enable-dark-mode: true !default;\n$color-mode-type: data !default; // `data` or `media-query`\n\n// Prefix for :root CSS variables\n\n$variable-prefix: bs- !default; // Deprecated in v5.2.0 for the shorter `$prefix`\n$prefix: $variable-prefix !default;\n\n// Gradient\n//\n// The gradient which is added to components if `$enable-gradients` is `true`\n// This gradient is also added to elements with `.bg-gradient`\n// scss-docs-start variable-gradient\n$gradient: linear-gradient(180deg, rgba($white, .15), rgba($white, 0)) !default;\n// scss-docs-end variable-gradient\n\n// Spacing\n//\n// Control the default styling of most Bootstrap elements by modifying these\n// variables. Mostly focused on spacing.\n// You can add more entries to the $spacers map, should you need more variation.\n\n// scss-docs-start spacer-variables-maps\n$spacer: 1rem !default;\n$spacers: (\n 0: 0,\n 1: $spacer * .25,\n 2: $spacer * .5,\n 3: $spacer,\n 4: $spacer * 1.5,\n 5: $spacer * 3,\n) !default;\n// scss-docs-end spacer-variables-maps\n\n// Position\n//\n// Define the edge positioning anchors of the position utilities.\n\n// scss-docs-start position-map\n$position-values: (\n 0: 0,\n 50: 50%,\n 100: 100%\n) !default;\n// scss-docs-end position-map\n\n// Body\n//\n// Settings for the `` element.\n\n$body-text-align: null !default;\n$body-color: $gray-900 !default;\n$body-bg: $white !default;\n\n$body-secondary-color: rgba($body-color, .75) !default;\n$body-secondary-bg: $gray-200 !default;\n\n$body-tertiary-color: rgba($body-color, .5) !default;\n$body-tertiary-bg: $gray-100 !default;\n\n$body-emphasis-color: $black !default;\n\n// Links\n//\n// Style anchor elements.\n\n$link-color: $primary !default;\n$link-decoration: underline !default;\n$link-shade-percentage: 20% !default;\n$link-hover-color: shift-color($link-color, $link-shade-percentage) !default;\n$link-hover-decoration: null !default;\n\n$stretched-link-pseudo-element: after !default;\n$stretched-link-z-index: 1 !default;\n\n// Icon links\n// scss-docs-start icon-link-variables\n$icon-link-gap: .375rem !default;\n$icon-link-underline-offset: .25em !default;\n$icon-link-icon-size: 1em !default;\n$icon-link-icon-transition: .2s ease-in-out transform !default;\n$icon-link-icon-transform: translate3d(.25em, 0, 0) !default;\n// scss-docs-end icon-link-variables\n\n// Paragraphs\n//\n// Style p element.\n\n$paragraph-margin-bottom: 1rem !default;\n\n\n// Grid breakpoints\n//\n// Define the minimum dimensions at which your layout will change,\n// adapting to different screen sizes, for use in media queries.\n\n// scss-docs-start grid-breakpoints\n$grid-breakpoints: (\n xs: 0,\n sm: 576px,\n md: 768px,\n lg: 992px,\n xl: 1200px,\n xxl: 1400px\n) !default;\n// scss-docs-end grid-breakpoints\n\n@include _assert-ascending($grid-breakpoints, \"$grid-breakpoints\");\n@include _assert-starts-at-zero($grid-breakpoints, \"$grid-breakpoints\");\n\n\n// Grid containers\n//\n// Define the maximum width of `.container` for different screen sizes.\n\n// scss-docs-start container-max-widths\n$container-max-widths: (\n sm: 540px,\n md: 720px,\n lg: 960px,\n xl: 1140px,\n xxl: 1320px\n) !default;\n// scss-docs-end container-max-widths\n\n@include _assert-ascending($container-max-widths, \"$container-max-widths\");\n\n\n// Grid columns\n//\n// Set the number of columns and specify the width of the gutters.\n\n$grid-columns: 12 !default;\n$grid-gutter-width: 1.5rem !default;\n$grid-row-columns: 6 !default;\n\n// Container padding\n\n$container-padding-x: $grid-gutter-width !default;\n\n\n// Components\n//\n// Define common padding and border radius sizes and more.\n\n// scss-docs-start border-variables\n$border-width: 1px !default;\n$border-widths: (\n 1: 1px,\n 2: 2px,\n 3: 3px,\n 4: 4px,\n 5: 5px\n) !default;\n$border-style: solid !default;\n$border-color: $gray-300 !default;\n$border-color-translucent: rgba($black, .175) !default;\n// scss-docs-end border-variables\n\n// scss-docs-start border-radius-variables\n$border-radius: .375rem !default;\n$border-radius-sm: .25rem !default;\n$border-radius-lg: .5rem !default;\n$border-radius-xl: 1rem !default;\n$border-radius-xxl: 2rem !default;\n$border-radius-pill: 50rem !default;\n// scss-docs-end border-radius-variables\n// fusv-disable\n$border-radius-2xl: $border-radius-xxl !default; // Deprecated in v5.3.0\n// fusv-enable\n\n// scss-docs-start box-shadow-variables\n$box-shadow: 0 .5rem 1rem rgba($black, .15) !default;\n$box-shadow-sm: 0 .125rem .25rem rgba($black, .075) !default;\n$box-shadow-lg: 0 1rem 3rem rgba($black, .175) !default;\n$box-shadow-inset: inset 0 1px 2px rgba($black, .075) !default;\n// scss-docs-end box-shadow-variables\n\n$component-active-color: $white !default;\n$component-active-bg: $primary !default;\n\n// scss-docs-start focus-ring-variables\n$focus-ring-width: .25rem !default;\n$focus-ring-opacity: .25 !default;\n$focus-ring-color: rgba($primary, $focus-ring-opacity) !default;\n$focus-ring-blur: 0 !default;\n$focus-ring-box-shadow: 0 0 $focus-ring-blur $focus-ring-width $focus-ring-color !default;\n// scss-docs-end focus-ring-variables\n\n// scss-docs-start caret-variables\n$caret-width: .3em !default;\n$caret-vertical-align: $caret-width * .85 !default;\n$caret-spacing: $caret-width * .85 !default;\n// scss-docs-end caret-variables\n\n$transition-base: all .2s ease-in-out !default;\n$transition-fade: opacity .15s linear !default;\n// scss-docs-start collapse-transition\n$transition-collapse: height .35s ease !default;\n$transition-collapse-width: width .35s ease !default;\n// scss-docs-end collapse-transition\n\n// stylelint-disable function-disallowed-list\n// scss-docs-start aspect-ratios\n$aspect-ratios: (\n \"1x1\": 100%,\n \"4x3\": calc(3 / 4 * 100%),\n \"16x9\": calc(9 / 16 * 100%),\n \"21x9\": calc(9 / 21 * 100%)\n) !default;\n// scss-docs-end aspect-ratios\n// stylelint-enable function-disallowed-list\n\n// Typography\n//\n// Font, line-height, and color for body text, headings, and more.\n\n// scss-docs-start font-variables\n// stylelint-disable value-keyword-case\n$font-family-sans-serif: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\" !default;\n$font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace !default;\n// stylelint-enable value-keyword-case\n$font-family-base: var(--#{$prefix}font-sans-serif) !default;\n$font-family-code: var(--#{$prefix}font-monospace) !default;\n\n// $font-size-root affects the value of `rem`, which is used for as well font sizes, paddings, and margins\n// $font-size-base affects the font size of the body text\n$font-size-root: null !default;\n$font-size-base: 1rem !default; // Assumes the browser default, typically `16px`\n$font-size-sm: $font-size-base * .875 !default;\n$font-size-lg: $font-size-base * 1.25 !default;\n\n$font-weight-lighter: lighter !default;\n$font-weight-light: 300 !default;\n$font-weight-normal: 400 !default;\n$font-weight-medium: 500 !default;\n$font-weight-semibold: 600 !default;\n$font-weight-bold: 700 !default;\n$font-weight-bolder: bolder !default;\n\n$font-weight-base: $font-weight-normal !default;\n\n$line-height-base: 1.5 !default;\n$line-height-sm: 1.25 !default;\n$line-height-lg: 2 !default;\n\n$h1-font-size: $font-size-base * 2.5 !default;\n$h2-font-size: $font-size-base * 2 !default;\n$h3-font-size: $font-size-base * 1.75 !default;\n$h4-font-size: $font-size-base * 1.5 !default;\n$h5-font-size: $font-size-base * 1.25 !default;\n$h6-font-size: $font-size-base !default;\n// scss-docs-end font-variables\n\n// scss-docs-start font-sizes\n$font-sizes: (\n 1: $h1-font-size,\n 2: $h2-font-size,\n 3: $h3-font-size,\n 4: $h4-font-size,\n 5: $h5-font-size,\n 6: $h6-font-size\n) !default;\n// scss-docs-end font-sizes\n\n// scss-docs-start headings-variables\n$headings-margin-bottom: $spacer * .5 !default;\n$headings-font-family: null !default;\n$headings-font-style: null !default;\n$headings-font-weight: 500 !default;\n$headings-line-height: 1.2 !default;\n$headings-color: inherit !default;\n// scss-docs-end headings-variables\n\n// scss-docs-start display-headings\n$display-font-sizes: (\n 1: 5rem,\n 2: 4.5rem,\n 3: 4rem,\n 4: 3.5rem,\n 5: 3rem,\n 6: 2.5rem\n) !default;\n\n$display-font-family: null !default;\n$display-font-style: null !default;\n$display-font-weight: 300 !default;\n$display-line-height: $headings-line-height !default;\n// scss-docs-end display-headings\n\n// scss-docs-start type-variables\n$lead-font-size: $font-size-base * 1.25 !default;\n$lead-font-weight: 300 !default;\n\n$small-font-size: .875em !default;\n\n$sub-sup-font-size: .75em !default;\n\n// fusv-disable\n$text-muted: var(--#{$prefix}secondary-color) !default; // Deprecated in 5.3.0\n// fusv-enable\n\n$initialism-font-size: $small-font-size !default;\n\n$blockquote-margin-y: $spacer !default;\n$blockquote-font-size: $font-size-base * 1.25 !default;\n$blockquote-footer-color: $gray-600 !default;\n$blockquote-footer-font-size: $small-font-size !default;\n\n$hr-margin-y: $spacer !default;\n$hr-color: inherit !default;\n\n// fusv-disable\n$hr-bg-color: null !default; // Deprecated in v5.2.0\n$hr-height: null !default; // Deprecated in v5.2.0\n// fusv-enable\n\n$hr-border-color: null !default; // Allows for inherited colors\n$hr-border-width: var(--#{$prefix}border-width) !default;\n$hr-opacity: .25 !default;\n\n// scss-docs-start vr-variables\n$vr-border-width: var(--#{$prefix}border-width) !default;\n// scss-docs-end vr-variables\n\n$legend-margin-bottom: .5rem !default;\n$legend-font-size: 1.5rem !default;\n$legend-font-weight: null !default;\n\n$dt-font-weight: $font-weight-bold !default;\n\n$list-inline-padding: .5rem !default;\n\n$mark-padding: .1875em !default;\n$mark-color: $body-color !default;\n$mark-bg: $yellow-100 !default;\n// scss-docs-end type-variables\n\n\n// Tables\n//\n// Customizes the `.table` component with basic values, each used across all table variations.\n\n// scss-docs-start table-variables\n$table-cell-padding-y: .5rem !default;\n$table-cell-padding-x: .5rem !default;\n$table-cell-padding-y-sm: .25rem !default;\n$table-cell-padding-x-sm: .25rem !default;\n\n$table-cell-vertical-align: top !default;\n\n$table-color: var(--#{$prefix}emphasis-color) !default;\n$table-bg: var(--#{$prefix}body-bg) !default;\n$table-accent-bg: transparent !default;\n\n$table-th-font-weight: null !default;\n\n$table-striped-color: $table-color !default;\n$table-striped-bg-factor: .05 !default;\n$table-striped-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-striped-bg-factor) !default;\n\n$table-active-color: $table-color !default;\n$table-active-bg-factor: .1 !default;\n$table-active-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-active-bg-factor) !default;\n\n$table-hover-color: $table-color !default;\n$table-hover-bg-factor: .075 !default;\n$table-hover-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-hover-bg-factor) !default;\n\n$table-border-factor: .2 !default;\n$table-border-width: var(--#{$prefix}border-width) !default;\n$table-border-color: var(--#{$prefix}border-color) !default;\n\n$table-striped-order: odd !default;\n$table-striped-columns-order: even !default;\n\n$table-group-separator-color: currentcolor !default;\n\n$table-caption-color: var(--#{$prefix}secondary-color) !default;\n\n$table-bg-scale: -80% !default;\n// scss-docs-end table-variables\n\n// scss-docs-start table-loop\n$table-variants: (\n \"primary\": shift-color($primary, $table-bg-scale),\n \"secondary\": shift-color($secondary, $table-bg-scale),\n \"success\": shift-color($success, $table-bg-scale),\n \"info\": shift-color($info, $table-bg-scale),\n \"warning\": shift-color($warning, $table-bg-scale),\n \"danger\": shift-color($danger, $table-bg-scale),\n \"light\": $light,\n \"dark\": $dark,\n) !default;\n// scss-docs-end table-loop\n\n\n// Buttons + Forms\n//\n// Shared variables that are reassigned to `$input-` and `$btn-` specific variables.\n\n// scss-docs-start input-btn-variables\n$input-btn-padding-y: .375rem !default;\n$input-btn-padding-x: .75rem !default;\n$input-btn-font-family: null !default;\n$input-btn-font-size: $font-size-base !default;\n$input-btn-line-height: $line-height-base !default;\n\n$input-btn-focus-width: $focus-ring-width !default;\n$input-btn-focus-color-opacity: $focus-ring-opacity !default;\n$input-btn-focus-color: $focus-ring-color !default;\n$input-btn-focus-blur: $focus-ring-blur !default;\n$input-btn-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$input-btn-padding-y-sm: .25rem !default;\n$input-btn-padding-x-sm: .5rem !default;\n$input-btn-font-size-sm: $font-size-sm !default;\n\n$input-btn-padding-y-lg: .5rem !default;\n$input-btn-padding-x-lg: 1rem !default;\n$input-btn-font-size-lg: $font-size-lg !default;\n\n$input-btn-border-width: var(--#{$prefix}border-width) !default;\n// scss-docs-end input-btn-variables\n\n\n// Buttons\n//\n// For each of Bootstrap's buttons, define text, background, and border color.\n\n// scss-docs-start btn-variables\n$btn-color: var(--#{$prefix}body-color) !default;\n$btn-padding-y: $input-btn-padding-y !default;\n$btn-padding-x: $input-btn-padding-x !default;\n$btn-font-family: $input-btn-font-family !default;\n$btn-font-size: $input-btn-font-size !default;\n$btn-line-height: $input-btn-line-height !default;\n$btn-white-space: null !default; // Set to `nowrap` to prevent text wrapping\n\n$btn-padding-y-sm: $input-btn-padding-y-sm !default;\n$btn-padding-x-sm: $input-btn-padding-x-sm !default;\n$btn-font-size-sm: $input-btn-font-size-sm !default;\n\n$btn-padding-y-lg: $input-btn-padding-y-lg !default;\n$btn-padding-x-lg: $input-btn-padding-x-lg !default;\n$btn-font-size-lg: $input-btn-font-size-lg !default;\n\n$btn-border-width: $input-btn-border-width !default;\n\n$btn-font-weight: $font-weight-normal !default;\n$btn-box-shadow: inset 0 1px 0 rgba($white, .15), 0 1px 1px rgba($black, .075) !default;\n$btn-focus-width: $input-btn-focus-width !default;\n$btn-focus-box-shadow: $input-btn-focus-box-shadow !default;\n$btn-disabled-opacity: .65 !default;\n$btn-active-box-shadow: inset 0 3px 5px rgba($black, .125) !default;\n\n$btn-link-color: var(--#{$prefix}link-color) !default;\n$btn-link-hover-color: var(--#{$prefix}link-hover-color) !default;\n$btn-link-disabled-color: $gray-600 !default;\n$btn-link-focus-shadow-rgb: to-rgb(mix(color-contrast($link-color), $link-color, 15%)) !default;\n\n// Allows for customizing button radius independently from global border radius\n$btn-border-radius: var(--#{$prefix}border-radius) !default;\n$btn-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$btn-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n\n$btn-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$btn-hover-bg-shade-amount: 15% !default;\n$btn-hover-bg-tint-amount: 15% !default;\n$btn-hover-border-shade-amount: 20% !default;\n$btn-hover-border-tint-amount: 10% !default;\n$btn-active-bg-shade-amount: 20% !default;\n$btn-active-bg-tint-amount: 20% !default;\n$btn-active-border-shade-amount: 25% !default;\n$btn-active-border-tint-amount: 10% !default;\n// scss-docs-end btn-variables\n\n\n// Forms\n\n// scss-docs-start form-text-variables\n$form-text-margin-top: .25rem !default;\n$form-text-font-size: $small-font-size !default;\n$form-text-font-style: null !default;\n$form-text-font-weight: null !default;\n$form-text-color: var(--#{$prefix}secondary-color) !default;\n// scss-docs-end form-text-variables\n\n// scss-docs-start form-label-variables\n$form-label-margin-bottom: .5rem !default;\n$form-label-font-size: null !default;\n$form-label-font-style: null !default;\n$form-label-font-weight: null !default;\n$form-label-color: null !default;\n// scss-docs-end form-label-variables\n\n// scss-docs-start form-input-variables\n$input-padding-y: $input-btn-padding-y !default;\n$input-padding-x: $input-btn-padding-x !default;\n$input-font-family: $input-btn-font-family !default;\n$input-font-size: $input-btn-font-size !default;\n$input-font-weight: $font-weight-base !default;\n$input-line-height: $input-btn-line-height !default;\n\n$input-padding-y-sm: $input-btn-padding-y-sm !default;\n$input-padding-x-sm: $input-btn-padding-x-sm !default;\n$input-font-size-sm: $input-btn-font-size-sm !default;\n\n$input-padding-y-lg: $input-btn-padding-y-lg !default;\n$input-padding-x-lg: $input-btn-padding-x-lg !default;\n$input-font-size-lg: $input-btn-font-size-lg !default;\n\n$input-bg: var(--#{$prefix}body-bg) !default;\n$input-disabled-color: null !default;\n$input-disabled-bg: var(--#{$prefix}secondary-bg) !default;\n$input-disabled-border-color: null !default;\n\n$input-color: var(--#{$prefix}body-color) !default;\n$input-border-color: var(--#{$prefix}border-color) !default;\n$input-border-width: $input-btn-border-width !default;\n$input-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$input-border-radius: var(--#{$prefix}border-radius) !default;\n$input-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$input-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n\n$input-focus-bg: $input-bg !default;\n$input-focus-border-color: tint-color($component-active-bg, 50%) !default;\n$input-focus-color: $input-color !default;\n$input-focus-width: $input-btn-focus-width !default;\n$input-focus-box-shadow: $input-btn-focus-box-shadow !default;\n\n$input-placeholder-color: var(--#{$prefix}secondary-color) !default;\n$input-plaintext-color: var(--#{$prefix}body-color) !default;\n\n$input-height-border: calc(#{$input-border-width} * 2) !default; // stylelint-disable-line function-disallowed-list\n\n$input-height-inner: add($input-line-height * 1em, $input-padding-y * 2) !default;\n$input-height-inner-half: add($input-line-height * .5em, $input-padding-y) !default;\n$input-height-inner-quarter: add($input-line-height * .25em, $input-padding-y * .5) !default;\n\n$input-height: add($input-line-height * 1em, add($input-padding-y * 2, $input-height-border, false)) !default;\n$input-height-sm: add($input-line-height * 1em, add($input-padding-y-sm * 2, $input-height-border, false)) !default;\n$input-height-lg: add($input-line-height * 1em, add($input-padding-y-lg * 2, $input-height-border, false)) !default;\n\n$input-transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$form-color-width: 3rem !default;\n// scss-docs-end form-input-variables\n\n// scss-docs-start form-check-variables\n$form-check-input-width: 1em !default;\n$form-check-min-height: $font-size-base * $line-height-base !default;\n$form-check-padding-start: $form-check-input-width + .5em !default;\n$form-check-margin-bottom: .125rem !default;\n$form-check-label-color: null !default;\n$form-check-label-cursor: null !default;\n$form-check-transition: null !default;\n\n$form-check-input-active-filter: brightness(90%) !default;\n\n$form-check-input-bg: $input-bg !default;\n$form-check-input-border: var(--#{$prefix}border-width) solid var(--#{$prefix}border-color) !default;\n$form-check-input-border-radius: .25em !default;\n$form-check-radio-border-radius: 50% !default;\n$form-check-input-focus-border: $input-focus-border-color !default;\n$form-check-input-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$form-check-input-checked-color: $component-active-color !default;\n$form-check-input-checked-bg-color: $component-active-bg !default;\n$form-check-input-checked-border-color: $form-check-input-checked-bg-color !default;\n$form-check-input-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-check-radio-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-check-input-indeterminate-color: $component-active-color !default;\n$form-check-input-indeterminate-bg-color: $component-active-bg !default;\n$form-check-input-indeterminate-border-color: $form-check-input-indeterminate-bg-color !default;\n$form-check-input-indeterminate-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-check-input-disabled-opacity: .5 !default;\n$form-check-label-disabled-opacity: $form-check-input-disabled-opacity !default;\n$form-check-btn-check-disabled-opacity: $btn-disabled-opacity !default;\n\n$form-check-inline-margin-end: 1rem !default;\n// scss-docs-end form-check-variables\n\n// scss-docs-start form-switch-variables\n$form-switch-color: rgba($black, .25) !default;\n$form-switch-width: 2em !default;\n$form-switch-padding-start: $form-switch-width + .5em !default;\n$form-switch-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-switch-border-radius: $form-switch-width !default;\n$form-switch-transition: background-position .15s ease-in-out !default;\n\n$form-switch-focus-color: $input-focus-border-color !default;\n$form-switch-focus-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-switch-checked-color: $component-active-color !default;\n$form-switch-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-switch-checked-bg-position: right center !default;\n// scss-docs-end form-switch-variables\n\n// scss-docs-start input-group-variables\n$input-group-addon-padding-y: $input-padding-y !default;\n$input-group-addon-padding-x: $input-padding-x !default;\n$input-group-addon-font-weight: $input-font-weight !default;\n$input-group-addon-color: $input-color !default;\n$input-group-addon-bg: var(--#{$prefix}tertiary-bg) !default;\n$input-group-addon-border-color: $input-border-color !default;\n// scss-docs-end input-group-variables\n\n// scss-docs-start form-select-variables\n$form-select-padding-y: $input-padding-y !default;\n$form-select-padding-x: $input-padding-x !default;\n$form-select-font-family: $input-font-family !default;\n$form-select-font-size: $input-font-size !default;\n$form-select-indicator-padding: $form-select-padding-x * 3 !default; // Extra padding for background-image\n$form-select-font-weight: $input-font-weight !default;\n$form-select-line-height: $input-line-height !default;\n$form-select-color: $input-color !default;\n$form-select-bg: $input-bg !default;\n$form-select-disabled-color: null !default;\n$form-select-disabled-bg: $input-disabled-bg !default;\n$form-select-disabled-border-color: $input-disabled-border-color !default;\n$form-select-bg-position: right $form-select-padding-x center !default;\n$form-select-bg-size: 16px 12px !default; // In pixels because image dimensions\n$form-select-indicator-color: $gray-800 !default;\n$form-select-indicator: url(\"data:image/svg+xml,\") !default;\n\n$form-select-feedback-icon-padding-end: $form-select-padding-x * 2.5 + $form-select-indicator-padding !default;\n$form-select-feedback-icon-position: center right $form-select-indicator-padding !default;\n$form-select-feedback-icon-size: $input-height-inner-half $input-height-inner-half !default;\n\n$form-select-border-width: $input-border-width !default;\n$form-select-border-color: $input-border-color !default;\n$form-select-border-radius: $input-border-radius !default;\n$form-select-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$form-select-focus-border-color: $input-focus-border-color !default;\n$form-select-focus-width: $input-focus-width !default;\n$form-select-focus-box-shadow: 0 0 0 $form-select-focus-width $input-btn-focus-color !default;\n\n$form-select-padding-y-sm: $input-padding-y-sm !default;\n$form-select-padding-x-sm: $input-padding-x-sm !default;\n$form-select-font-size-sm: $input-font-size-sm !default;\n$form-select-border-radius-sm: $input-border-radius-sm !default;\n\n$form-select-padding-y-lg: $input-padding-y-lg !default;\n$form-select-padding-x-lg: $input-padding-x-lg !default;\n$form-select-font-size-lg: $input-font-size-lg !default;\n$form-select-border-radius-lg: $input-border-radius-lg !default;\n\n$form-select-transition: $input-transition !default;\n// scss-docs-end form-select-variables\n\n// scss-docs-start form-range-variables\n$form-range-track-width: 100% !default;\n$form-range-track-height: .5rem !default;\n$form-range-track-cursor: pointer !default;\n$form-range-track-bg: var(--#{$prefix}secondary-bg) !default;\n$form-range-track-border-radius: 1rem !default;\n$form-range-track-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$form-range-thumb-width: 1rem !default;\n$form-range-thumb-height: $form-range-thumb-width !default;\n$form-range-thumb-bg: $component-active-bg !default;\n$form-range-thumb-border: 0 !default;\n$form-range-thumb-border-radius: 1rem !default;\n$form-range-thumb-box-shadow: 0 .1rem .25rem rgba($black, .1) !default;\n$form-range-thumb-focus-box-shadow: 0 0 0 1px $body-bg, $input-focus-box-shadow !default;\n$form-range-thumb-focus-box-shadow-width: $input-focus-width !default; // For focus box shadow issue in Edge\n$form-range-thumb-active-bg: tint-color($component-active-bg, 70%) !default;\n$form-range-thumb-disabled-bg: var(--#{$prefix}secondary-color) !default;\n$form-range-thumb-transition: background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n// scss-docs-end form-range-variables\n\n// scss-docs-start form-file-variables\n$form-file-button-color: $input-color !default;\n$form-file-button-bg: var(--#{$prefix}tertiary-bg) !default;\n$form-file-button-hover-bg: var(--#{$prefix}secondary-bg) !default;\n// scss-docs-end form-file-variables\n\n// scss-docs-start form-floating-variables\n$form-floating-height: add(3.5rem, $input-height-border) !default;\n$form-floating-line-height: 1.25 !default;\n$form-floating-padding-x: $input-padding-x !default;\n$form-floating-padding-y: 1rem !default;\n$form-floating-input-padding-t: 1.625rem !default;\n$form-floating-input-padding-b: .625rem !default;\n$form-floating-label-height: 1.5em !default;\n$form-floating-label-opacity: .65 !default;\n$form-floating-label-transform: scale(.85) translateY(-.5rem) translateX(.15rem) !default;\n$form-floating-label-disabled-color: $gray-600 !default;\n$form-floating-transition: opacity .1s ease-in-out, transform .1s ease-in-out !default;\n// scss-docs-end form-floating-variables\n\n// Form validation\n\n// scss-docs-start form-feedback-variables\n$form-feedback-margin-top: $form-text-margin-top !default;\n$form-feedback-font-size: $form-text-font-size !default;\n$form-feedback-font-style: $form-text-font-style !default;\n$form-feedback-valid-color: $success !default;\n$form-feedback-invalid-color: $danger !default;\n\n$form-feedback-icon-valid-color: $form-feedback-valid-color !default;\n$form-feedback-icon-valid: url(\"data:image/svg+xml,\") !default;\n$form-feedback-icon-invalid-color: $form-feedback-invalid-color !default;\n$form-feedback-icon-invalid: url(\"data:image/svg+xml,\") !default;\n// scss-docs-end form-feedback-variables\n\n// scss-docs-start form-validation-colors\n$form-valid-color: $form-feedback-valid-color !default;\n$form-valid-border-color: $form-feedback-valid-color !default;\n$form-invalid-color: $form-feedback-invalid-color !default;\n$form-invalid-border-color: $form-feedback-invalid-color !default;\n// scss-docs-end form-validation-colors\n\n// scss-docs-start form-validation-states\n$form-validation-states: (\n \"valid\": (\n \"color\": var(--#{$prefix}form-valid-color),\n \"icon\": $form-feedback-icon-valid,\n \"tooltip-color\": #fff,\n \"tooltip-bg-color\": var(--#{$prefix}success),\n \"focus-box-shadow\": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}success-rgb), $input-btn-focus-color-opacity),\n \"border-color\": var(--#{$prefix}form-valid-border-color),\n ),\n \"invalid\": (\n \"color\": var(--#{$prefix}form-invalid-color),\n \"icon\": $form-feedback-icon-invalid,\n \"tooltip-color\": #fff,\n \"tooltip-bg-color\": var(--#{$prefix}danger),\n \"focus-box-shadow\": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}danger-rgb), $input-btn-focus-color-opacity),\n \"border-color\": var(--#{$prefix}form-invalid-border-color),\n )\n) !default;\n// scss-docs-end form-validation-states\n\n// Z-index master list\n//\n// Warning: Avoid customizing these values. They're used for a bird's eye view\n// of components dependent on the z-axis and are designed to all work together.\n\n// scss-docs-start zindex-stack\n$zindex-dropdown: 1000 !default;\n$zindex-sticky: 1020 !default;\n$zindex-fixed: 1030 !default;\n$zindex-offcanvas-backdrop: 1040 !default;\n$zindex-offcanvas: 1045 !default;\n$zindex-modal-backdrop: 1050 !default;\n$zindex-modal: 1055 !default;\n$zindex-popover: 1070 !default;\n$zindex-tooltip: 1080 !default;\n$zindex-toast: 1090 !default;\n// scss-docs-end zindex-stack\n\n// scss-docs-start zindex-levels-map\n$zindex-levels: (\n n1: -1,\n 0: 0,\n 1: 1,\n 2: 2,\n 3: 3\n) !default;\n// scss-docs-end zindex-levels-map\n\n\n// Navs\n\n// scss-docs-start nav-variables\n$nav-link-padding-y: .5rem !default;\n$nav-link-padding-x: 1rem !default;\n$nav-link-font-size: null !default;\n$nav-link-font-weight: null !default;\n$nav-link-color: var(--#{$prefix}link-color) !default;\n$nav-link-hover-color: var(--#{$prefix}link-hover-color) !default;\n$nav-link-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out !default;\n$nav-link-disabled-color: var(--#{$prefix}secondary-color) !default;\n$nav-link-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$nav-tabs-border-color: var(--#{$prefix}border-color) !default;\n$nav-tabs-border-width: var(--#{$prefix}border-width) !default;\n$nav-tabs-border-radius: var(--#{$prefix}border-radius) !default;\n$nav-tabs-link-hover-border-color: var(--#{$prefix}secondary-bg) var(--#{$prefix}secondary-bg) $nav-tabs-border-color !default;\n$nav-tabs-link-active-color: var(--#{$prefix}emphasis-color) !default;\n$nav-tabs-link-active-bg: var(--#{$prefix}body-bg) !default;\n$nav-tabs-link-active-border-color: var(--#{$prefix}border-color) var(--#{$prefix}border-color) $nav-tabs-link-active-bg !default;\n\n$nav-pills-border-radius: var(--#{$prefix}border-radius) !default;\n$nav-pills-link-active-color: $component-active-color !default;\n$nav-pills-link-active-bg: $component-active-bg !default;\n\n$nav-underline-gap: 1rem !default;\n$nav-underline-border-width: .125rem !default;\n$nav-underline-link-active-color: var(--#{$prefix}emphasis-color) !default;\n// scss-docs-end nav-variables\n\n\n// Navbar\n\n// scss-docs-start navbar-variables\n$navbar-padding-y: $spacer * .5 !default;\n$navbar-padding-x: null !default;\n\n$navbar-nav-link-padding-x: .5rem !default;\n\n$navbar-brand-font-size: $font-size-lg !default;\n// Compute the navbar-brand padding-y so the navbar-brand will have the same height as navbar-text and nav-link\n$nav-link-height: $font-size-base * $line-height-base + $nav-link-padding-y * 2 !default;\n$navbar-brand-height: $navbar-brand-font-size * $line-height-base !default;\n$navbar-brand-padding-y: ($nav-link-height - $navbar-brand-height) * .5 !default;\n$navbar-brand-margin-end: 1rem !default;\n\n$navbar-toggler-padding-y: .25rem !default;\n$navbar-toggler-padding-x: .75rem !default;\n$navbar-toggler-font-size: $font-size-lg !default;\n$navbar-toggler-border-radius: $btn-border-radius !default;\n$navbar-toggler-focus-width: $btn-focus-width !default;\n$navbar-toggler-transition: box-shadow .15s ease-in-out !default;\n\n$navbar-light-color: rgba(var(--#{$prefix}emphasis-color-rgb), .65) !default;\n$navbar-light-hover-color: rgba(var(--#{$prefix}emphasis-color-rgb), .8) !default;\n$navbar-light-active-color: rgba(var(--#{$prefix}emphasis-color-rgb), 1) !default;\n$navbar-light-disabled-color: rgba(var(--#{$prefix}emphasis-color-rgb), .3) !default;\n$navbar-light-icon-color: rgba($body-color, .75) !default;\n$navbar-light-toggler-icon-bg: url(\"data:image/svg+xml,\") !default;\n$navbar-light-toggler-border-color: rgba(var(--#{$prefix}emphasis-color-rgb), .15) !default;\n$navbar-light-brand-color: $navbar-light-active-color !default;\n$navbar-light-brand-hover-color: $navbar-light-active-color !default;\n// scss-docs-end navbar-variables\n\n// scss-docs-start navbar-dark-variables\n$navbar-dark-color: rgba($white, .55) !default;\n$navbar-dark-hover-color: rgba($white, .75) !default;\n$navbar-dark-active-color: $white !default;\n$navbar-dark-disabled-color: rgba($white, .25) !default;\n$navbar-dark-icon-color: $navbar-dark-color !default;\n$navbar-dark-toggler-icon-bg: url(\"data:image/svg+xml,\") !default;\n$navbar-dark-toggler-border-color: rgba($white, .1) !default;\n$navbar-dark-brand-color: $navbar-dark-active-color !default;\n$navbar-dark-brand-hover-color: $navbar-dark-active-color !default;\n// scss-docs-end navbar-dark-variables\n\n\n// Dropdowns\n//\n// Dropdown menu container and contents.\n\n// scss-docs-start dropdown-variables\n$dropdown-min-width: 10rem !default;\n$dropdown-padding-x: 0 !default;\n$dropdown-padding-y: .5rem !default;\n$dropdown-spacer: .125rem !default;\n$dropdown-font-size: $font-size-base !default;\n$dropdown-color: var(--#{$prefix}body-color) !default;\n$dropdown-bg: var(--#{$prefix}body-bg) !default;\n$dropdown-border-color: var(--#{$prefix}border-color-translucent) !default;\n$dropdown-border-radius: var(--#{$prefix}border-radius) !default;\n$dropdown-border-width: var(--#{$prefix}border-width) !default;\n$dropdown-inner-border-radius: calc(#{$dropdown-border-radius} - #{$dropdown-border-width}) !default; // stylelint-disable-line function-disallowed-list\n$dropdown-divider-bg: $dropdown-border-color !default;\n$dropdown-divider-margin-y: $spacer * .5 !default;\n$dropdown-box-shadow: var(--#{$prefix}box-shadow) !default;\n\n$dropdown-link-color: var(--#{$prefix}body-color) !default;\n$dropdown-link-hover-color: $dropdown-link-color !default;\n$dropdown-link-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n\n$dropdown-link-active-color: $component-active-color !default;\n$dropdown-link-active-bg: $component-active-bg !default;\n\n$dropdown-link-disabled-color: var(--#{$prefix}tertiary-color) !default;\n\n$dropdown-item-padding-y: $spacer * .25 !default;\n$dropdown-item-padding-x: $spacer !default;\n\n$dropdown-header-color: $gray-600 !default;\n$dropdown-header-padding-x: $dropdown-item-padding-x !default;\n$dropdown-header-padding-y: $dropdown-padding-y !default;\n// fusv-disable\n$dropdown-header-padding: $dropdown-header-padding-y $dropdown-header-padding-x !default; // Deprecated in v5.2.0\n// fusv-enable\n// scss-docs-end dropdown-variables\n\n// scss-docs-start dropdown-dark-variables\n$dropdown-dark-color: $gray-300 !default;\n$dropdown-dark-bg: $gray-800 !default;\n$dropdown-dark-border-color: $dropdown-border-color !default;\n$dropdown-dark-divider-bg: $dropdown-divider-bg !default;\n$dropdown-dark-box-shadow: null !default;\n$dropdown-dark-link-color: $dropdown-dark-color !default;\n$dropdown-dark-link-hover-color: $white !default;\n$dropdown-dark-link-hover-bg: rgba($white, .15) !default;\n$dropdown-dark-link-active-color: $dropdown-link-active-color !default;\n$dropdown-dark-link-active-bg: $dropdown-link-active-bg !default;\n$dropdown-dark-link-disabled-color: $gray-500 !default;\n$dropdown-dark-header-color: $gray-500 !default;\n// scss-docs-end dropdown-dark-variables\n\n\n// Pagination\n\n// scss-docs-start pagination-variables\n$pagination-padding-y: .375rem !default;\n$pagination-padding-x: .75rem !default;\n$pagination-padding-y-sm: .25rem !default;\n$pagination-padding-x-sm: .5rem !default;\n$pagination-padding-y-lg: .75rem !default;\n$pagination-padding-x-lg: 1.5rem !default;\n\n$pagination-font-size: $font-size-base !default;\n\n$pagination-color: var(--#{$prefix}link-color) !default;\n$pagination-bg: var(--#{$prefix}body-bg) !default;\n$pagination-border-radius: var(--#{$prefix}border-radius) !default;\n$pagination-border-width: var(--#{$prefix}border-width) !default;\n$pagination-margin-start: calc(#{$pagination-border-width} * -1) !default; // stylelint-disable-line function-disallowed-list\n$pagination-border-color: var(--#{$prefix}border-color) !default;\n\n$pagination-focus-color: var(--#{$prefix}link-hover-color) !default;\n$pagination-focus-bg: var(--#{$prefix}secondary-bg) !default;\n$pagination-focus-box-shadow: $focus-ring-box-shadow !default;\n$pagination-focus-outline: 0 !default;\n\n$pagination-hover-color: var(--#{$prefix}link-hover-color) !default;\n$pagination-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n$pagination-hover-border-color: var(--#{$prefix}border-color) !default; // Todo in v6: remove this?\n\n$pagination-active-color: $component-active-color !default;\n$pagination-active-bg: $component-active-bg !default;\n$pagination-active-border-color: $component-active-bg !default;\n\n$pagination-disabled-color: var(--#{$prefix}secondary-color) !default;\n$pagination-disabled-bg: var(--#{$prefix}secondary-bg) !default;\n$pagination-disabled-border-color: var(--#{$prefix}border-color) !default;\n\n$pagination-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$pagination-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$pagination-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n// scss-docs-end pagination-variables\n\n\n// Placeholders\n\n// scss-docs-start placeholders\n$placeholder-opacity-max: .5 !default;\n$placeholder-opacity-min: .2 !default;\n// scss-docs-end placeholders\n\n// Cards\n\n// scss-docs-start card-variables\n$card-spacer-y: $spacer !default;\n$card-spacer-x: $spacer !default;\n$card-title-spacer-y: $spacer * .5 !default;\n$card-title-color: null !default;\n$card-subtitle-color: null !default;\n$card-border-width: var(--#{$prefix}border-width) !default;\n$card-border-color: var(--#{$prefix}border-color-translucent) !default;\n$card-border-radius: var(--#{$prefix}border-radius) !default;\n$card-box-shadow: null !default;\n$card-inner-border-radius: subtract($card-border-radius, $card-border-width) !default;\n$card-cap-padding-y: $card-spacer-y * .5 !default;\n$card-cap-padding-x: $card-spacer-x !default;\n$card-cap-bg: rgba(var(--#{$prefix}body-color-rgb), .03) !default;\n$card-cap-color: null !default;\n$card-height: null !default;\n$card-color: null !default;\n$card-bg: var(--#{$prefix}body-bg) !default;\n$card-img-overlay-padding: $spacer !default;\n$card-group-margin: $grid-gutter-width * .5 !default;\n// scss-docs-end card-variables\n\n// Accordion\n\n// scss-docs-start accordion-variables\n$accordion-padding-y: 1rem !default;\n$accordion-padding-x: 1.25rem !default;\n$accordion-color: var(--#{$prefix}body-color) !default;\n$accordion-bg: var(--#{$prefix}body-bg) !default;\n$accordion-border-width: var(--#{$prefix}border-width) !default;\n$accordion-border-color: var(--#{$prefix}border-color) !default;\n$accordion-border-radius: var(--#{$prefix}border-radius) !default;\n$accordion-inner-border-radius: subtract($accordion-border-radius, $accordion-border-width) !default;\n\n$accordion-body-padding-y: $accordion-padding-y !default;\n$accordion-body-padding-x: $accordion-padding-x !default;\n\n$accordion-button-padding-y: $accordion-padding-y !default;\n$accordion-button-padding-x: $accordion-padding-x !default;\n$accordion-button-color: var(--#{$prefix}body-color) !default;\n$accordion-button-bg: var(--#{$prefix}accordion-bg) !default;\n$accordion-transition: $btn-transition, border-radius .15s ease !default;\n$accordion-button-active-bg: var(--#{$prefix}primary-bg-subtle) !default;\n$accordion-button-active-color: var(--#{$prefix}primary-text-emphasis) !default;\n\n// fusv-disable\n$accordion-button-focus-border-color: $input-focus-border-color !default; // Deprecated in v5.3.3\n// fusv-enable\n$accordion-button-focus-box-shadow: $btn-focus-box-shadow !default;\n\n$accordion-icon-width: 1.25rem !default;\n$accordion-icon-color: $body-color !default;\n$accordion-icon-active-color: $primary-text-emphasis !default;\n$accordion-icon-transition: transform .2s ease-in-out !default;\n$accordion-icon-transform: rotate(-180deg) !default;\n\n$accordion-button-icon: url(\"data:image/svg+xml,\") !default;\n$accordion-button-active-icon: url(\"data:image/svg+xml,\") !default;\n// scss-docs-end accordion-variables\n\n// Tooltips\n\n// scss-docs-start tooltip-variables\n$tooltip-font-size: $font-size-sm !default;\n$tooltip-max-width: 200px !default;\n$tooltip-color: var(--#{$prefix}body-bg) !default;\n$tooltip-bg: var(--#{$prefix}emphasis-color) !default;\n$tooltip-border-radius: var(--#{$prefix}border-radius) !default;\n$tooltip-opacity: .9 !default;\n$tooltip-padding-y: $spacer * .25 !default;\n$tooltip-padding-x: $spacer * .5 !default;\n$tooltip-margin: null !default; // TODO: remove this in v6\n\n$tooltip-arrow-width: .8rem !default;\n$tooltip-arrow-height: .4rem !default;\n// fusv-disable\n$tooltip-arrow-color: null !default; // Deprecated in Bootstrap 5.2.0 for CSS variables\n// fusv-enable\n// scss-docs-end tooltip-variables\n\n// Form tooltips must come after regular tooltips\n// scss-docs-start tooltip-feedback-variables\n$form-feedback-tooltip-padding-y: $tooltip-padding-y !default;\n$form-feedback-tooltip-padding-x: $tooltip-padding-x !default;\n$form-feedback-tooltip-font-size: $tooltip-font-size !default;\n$form-feedback-tooltip-line-height: null !default;\n$form-feedback-tooltip-opacity: $tooltip-opacity !default;\n$form-feedback-tooltip-border-radius: $tooltip-border-radius !default;\n// scss-docs-end tooltip-feedback-variables\n\n\n// Popovers\n\n// scss-docs-start popover-variables\n$popover-font-size: $font-size-sm !default;\n$popover-bg: var(--#{$prefix}body-bg) !default;\n$popover-max-width: 276px !default;\n$popover-border-width: var(--#{$prefix}border-width) !default;\n$popover-border-color: var(--#{$prefix}border-color-translucent) !default;\n$popover-border-radius: var(--#{$prefix}border-radius-lg) !default;\n$popover-inner-border-radius: calc(#{$popover-border-radius} - #{$popover-border-width}) !default; // stylelint-disable-line function-disallowed-list\n$popover-box-shadow: var(--#{$prefix}box-shadow) !default;\n\n$popover-header-font-size: $font-size-base !default;\n$popover-header-bg: var(--#{$prefix}secondary-bg) !default;\n$popover-header-color: $headings-color !default;\n$popover-header-padding-y: .5rem !default;\n$popover-header-padding-x: $spacer !default;\n\n$popover-body-color: var(--#{$prefix}body-color) !default;\n$popover-body-padding-y: $spacer !default;\n$popover-body-padding-x: $spacer !default;\n\n$popover-arrow-width: 1rem !default;\n$popover-arrow-height: .5rem !default;\n// scss-docs-end popover-variables\n\n// fusv-disable\n// Deprecated in Bootstrap 5.2.0 for CSS variables\n$popover-arrow-color: $popover-bg !default;\n$popover-arrow-outer-color: var(--#{$prefix}border-color-translucent) !default;\n// fusv-enable\n\n\n// Toasts\n\n// scss-docs-start toast-variables\n$toast-max-width: 350px !default;\n$toast-padding-x: .75rem !default;\n$toast-padding-y: .5rem !default;\n$toast-font-size: .875rem !default;\n$toast-color: null !default;\n$toast-background-color: rgba(var(--#{$prefix}body-bg-rgb), .85) !default;\n$toast-border-width: var(--#{$prefix}border-width) !default;\n$toast-border-color: var(--#{$prefix}border-color-translucent) !default;\n$toast-border-radius: var(--#{$prefix}border-radius) !default;\n$toast-box-shadow: var(--#{$prefix}box-shadow) !default;\n$toast-spacing: $container-padding-x !default;\n\n$toast-header-color: var(--#{$prefix}secondary-color) !default;\n$toast-header-background-color: rgba(var(--#{$prefix}body-bg-rgb), .85) !default;\n$toast-header-border-color: $toast-border-color !default;\n// scss-docs-end toast-variables\n\n\n// Badges\n\n// scss-docs-start badge-variables\n$badge-font-size: .75em !default;\n$badge-font-weight: $font-weight-bold !default;\n$badge-color: $white !default;\n$badge-padding-y: .35em !default;\n$badge-padding-x: .65em !default;\n$badge-border-radius: var(--#{$prefix}border-radius) !default;\n// scss-docs-end badge-variables\n\n\n// Modals\n\n// scss-docs-start modal-variables\n$modal-inner-padding: $spacer !default;\n\n$modal-footer-margin-between: .5rem !default;\n\n$modal-dialog-margin: .5rem !default;\n$modal-dialog-margin-y-sm-up: 1.75rem !default;\n\n$modal-title-line-height: $line-height-base !default;\n\n$modal-content-color: null !default;\n$modal-content-bg: var(--#{$prefix}body-bg) !default;\n$modal-content-border-color: var(--#{$prefix}border-color-translucent) !default;\n$modal-content-border-width: var(--#{$prefix}border-width) !default;\n$modal-content-border-radius: var(--#{$prefix}border-radius-lg) !default;\n$modal-content-inner-border-radius: subtract($modal-content-border-radius, $modal-content-border-width) !default;\n$modal-content-box-shadow-xs: var(--#{$prefix}box-shadow-sm) !default;\n$modal-content-box-shadow-sm-up: var(--#{$prefix}box-shadow) !default;\n\n$modal-backdrop-bg: $black !default;\n$modal-backdrop-opacity: .5 !default;\n\n$modal-header-border-color: var(--#{$prefix}border-color) !default;\n$modal-header-border-width: $modal-content-border-width !default;\n$modal-header-padding-y: $modal-inner-padding !default;\n$modal-header-padding-x: $modal-inner-padding !default;\n$modal-header-padding: $modal-header-padding-y $modal-header-padding-x !default; // Keep this for backwards compatibility\n\n$modal-footer-bg: null !default;\n$modal-footer-border-color: $modal-header-border-color !default;\n$modal-footer-border-width: $modal-header-border-width !default;\n\n$modal-sm: 300px !default;\n$modal-md: 500px !default;\n$modal-lg: 800px !default;\n$modal-xl: 1140px !default;\n\n$modal-fade-transform: translate(0, -50px) !default;\n$modal-show-transform: none !default;\n$modal-transition: transform .3s ease-out !default;\n$modal-scale-transform: scale(1.02) !default;\n// scss-docs-end modal-variables\n\n\n// Alerts\n//\n// Define alert colors, border radius, and padding.\n\n// scss-docs-start alert-variables\n$alert-padding-y: $spacer !default;\n$alert-padding-x: $spacer !default;\n$alert-margin-bottom: 1rem !default;\n$alert-border-radius: var(--#{$prefix}border-radius) !default;\n$alert-link-font-weight: $font-weight-bold !default;\n$alert-border-width: var(--#{$prefix}border-width) !default;\n$alert-dismissible-padding-r: $alert-padding-x * 3 !default; // 3x covers width of x plus default padding on either side\n// scss-docs-end alert-variables\n\n// fusv-disable\n$alert-bg-scale: -80% !default; // Deprecated in v5.2.0, to be removed in v6\n$alert-border-scale: -70% !default; // Deprecated in v5.2.0, to be removed in v6\n$alert-color-scale: 40% !default; // Deprecated in v5.2.0, to be removed in v6\n// fusv-enable\n\n// Progress bars\n\n// scss-docs-start progress-variables\n$progress-height: 1rem !default;\n$progress-font-size: $font-size-base * .75 !default;\n$progress-bg: var(--#{$prefix}secondary-bg) !default;\n$progress-border-radius: var(--#{$prefix}border-radius) !default;\n$progress-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n$progress-bar-color: $white !default;\n$progress-bar-bg: $primary !default;\n$progress-bar-animation-timing: 1s linear infinite !default;\n$progress-bar-transition: width .6s ease !default;\n// scss-docs-end progress-variables\n\n\n// List group\n\n// scss-docs-start list-group-variables\n$list-group-color: var(--#{$prefix}body-color) !default;\n$list-group-bg: var(--#{$prefix}body-bg) !default;\n$list-group-border-color: var(--#{$prefix}border-color) !default;\n$list-group-border-width: var(--#{$prefix}border-width) !default;\n$list-group-border-radius: var(--#{$prefix}border-radius) !default;\n\n$list-group-item-padding-y: $spacer * .5 !default;\n$list-group-item-padding-x: $spacer !default;\n// fusv-disable\n$list-group-item-bg-scale: -80% !default; // Deprecated in v5.3.0\n$list-group-item-color-scale: 40% !default; // Deprecated in v5.3.0\n// fusv-enable\n\n$list-group-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n$list-group-active-color: $component-active-color !default;\n$list-group-active-bg: $component-active-bg !default;\n$list-group-active-border-color: $list-group-active-bg !default;\n\n$list-group-disabled-color: var(--#{$prefix}secondary-color) !default;\n$list-group-disabled-bg: $list-group-bg !default;\n\n$list-group-action-color: var(--#{$prefix}secondary-color) !default;\n$list-group-action-hover-color: var(--#{$prefix}emphasis-color) !default;\n\n$list-group-action-active-color: var(--#{$prefix}body-color) !default;\n$list-group-action-active-bg: var(--#{$prefix}secondary-bg) !default;\n// scss-docs-end list-group-variables\n\n\n// Image thumbnails\n\n// scss-docs-start thumbnail-variables\n$thumbnail-padding: .25rem !default;\n$thumbnail-bg: var(--#{$prefix}body-bg) !default;\n$thumbnail-border-width: var(--#{$prefix}border-width) !default;\n$thumbnail-border-color: var(--#{$prefix}border-color) !default;\n$thumbnail-border-radius: var(--#{$prefix}border-radius) !default;\n$thumbnail-box-shadow: var(--#{$prefix}box-shadow-sm) !default;\n// scss-docs-end thumbnail-variables\n\n\n// Figures\n\n// scss-docs-start figure-variables\n$figure-caption-font-size: $small-font-size !default;\n$figure-caption-color: var(--#{$prefix}secondary-color) !default;\n// scss-docs-end figure-variables\n\n\n// Breadcrumbs\n\n// scss-docs-start breadcrumb-variables\n$breadcrumb-font-size: null !default;\n$breadcrumb-padding-y: 0 !default;\n$breadcrumb-padding-x: 0 !default;\n$breadcrumb-item-padding-x: .5rem !default;\n$breadcrumb-margin-bottom: 1rem !default;\n$breadcrumb-bg: null !default;\n$breadcrumb-divider-color: var(--#{$prefix}secondary-color) !default;\n$breadcrumb-active-color: var(--#{$prefix}secondary-color) !default;\n$breadcrumb-divider: quote(\"/\") !default;\n$breadcrumb-divider-flipped: $breadcrumb-divider !default;\n$breadcrumb-border-radius: null !default;\n// scss-docs-end breadcrumb-variables\n\n// Carousel\n\n// scss-docs-start carousel-variables\n$carousel-control-color: $white !default;\n$carousel-control-width: 15% !default;\n$carousel-control-opacity: .5 !default;\n$carousel-control-hover-opacity: .9 !default;\n$carousel-control-transition: opacity .15s ease !default;\n\n$carousel-indicator-width: 30px !default;\n$carousel-indicator-height: 3px !default;\n$carousel-indicator-hit-area-height: 10px !default;\n$carousel-indicator-spacer: 3px !default;\n$carousel-indicator-opacity: .5 !default;\n$carousel-indicator-active-bg: $white !default;\n$carousel-indicator-active-opacity: 1 !default;\n$carousel-indicator-transition: opacity .6s ease !default;\n\n$carousel-caption-width: 70% !default;\n$carousel-caption-color: $white !default;\n$carousel-caption-padding-y: 1.25rem !default;\n$carousel-caption-spacer: 1.25rem !default;\n\n$carousel-control-icon-width: 2rem !default;\n\n$carousel-control-prev-icon-bg: url(\"data:image/svg+xml,\") !default;\n$carousel-control-next-icon-bg: url(\"data:image/svg+xml,\") !default;\n\n$carousel-transition-duration: .6s !default;\n$carousel-transition: transform $carousel-transition-duration ease-in-out !default; // Define transform transition first if using multiple transitions (e.g., `transform 2s ease, opacity .5s ease-out`)\n// scss-docs-end carousel-variables\n\n// scss-docs-start carousel-dark-variables\n$carousel-dark-indicator-active-bg: $black !default;\n$carousel-dark-caption-color: $black !default;\n$carousel-dark-control-icon-filter: invert(1) grayscale(100) !default;\n// scss-docs-end carousel-dark-variables\n\n\n// Spinners\n\n// scss-docs-start spinner-variables\n$spinner-width: 2rem !default;\n$spinner-height: $spinner-width !default;\n$spinner-vertical-align: -.125em !default;\n$spinner-border-width: .25em !default;\n$spinner-animation-speed: .75s !default;\n\n$spinner-width-sm: 1rem !default;\n$spinner-height-sm: $spinner-width-sm !default;\n$spinner-border-width-sm: .2em !default;\n// scss-docs-end spinner-variables\n\n\n// Close\n\n// scss-docs-start close-variables\n$btn-close-width: 1em !default;\n$btn-close-height: $btn-close-width !default;\n$btn-close-padding-x: .25em !default;\n$btn-close-padding-y: $btn-close-padding-x !default;\n$btn-close-color: $black !default;\n$btn-close-bg: url(\"data:image/svg+xml,\") !default;\n$btn-close-focus-shadow: $focus-ring-box-shadow !default;\n$btn-close-opacity: .5 !default;\n$btn-close-hover-opacity: .75 !default;\n$btn-close-focus-opacity: 1 !default;\n$btn-close-disabled-opacity: .25 !default;\n$btn-close-white-filter: invert(1) grayscale(100%) brightness(200%) !default;\n// scss-docs-end close-variables\n\n\n// Offcanvas\n\n// scss-docs-start offcanvas-variables\n$offcanvas-padding-y: $modal-inner-padding !default;\n$offcanvas-padding-x: $modal-inner-padding !default;\n$offcanvas-horizontal-width: 400px !default;\n$offcanvas-vertical-height: 30vh !default;\n$offcanvas-transition-duration: .3s !default;\n$offcanvas-border-color: $modal-content-border-color !default;\n$offcanvas-border-width: $modal-content-border-width !default;\n$offcanvas-title-line-height: $modal-title-line-height !default;\n$offcanvas-bg-color: var(--#{$prefix}body-bg) !default;\n$offcanvas-color: var(--#{$prefix}body-color) !default;\n$offcanvas-box-shadow: $modal-content-box-shadow-xs !default;\n$offcanvas-backdrop-bg: $modal-backdrop-bg !default;\n$offcanvas-backdrop-opacity: $modal-backdrop-opacity !default;\n// scss-docs-end offcanvas-variables\n\n// Code\n\n$code-font-size: $small-font-size !default;\n$code-color: $pink !default;\n\n$kbd-padding-y: .1875rem !default;\n$kbd-padding-x: .375rem !default;\n$kbd-font-size: $code-font-size !default;\n$kbd-color: var(--#{$prefix}body-bg) !default;\n$kbd-bg: var(--#{$prefix}body-color) !default;\n$nested-kbd-font-weight: null !default; // Deprecated in v5.2.0, removing in v6\n\n$pre-color: null !default;\n\n@import \"variables-dark\"; // TODO: can be removed safely in v6, only here to avoid breaking changes in v5.3\n","// Row\n//\n// Rows contain your columns.\n\n:root {\n @each $name, $value in $grid-breakpoints {\n --#{$prefix}breakpoint-#{$name}: #{$value};\n }\n}\n\n@if $enable-grid-classes {\n .row {\n @include make-row();\n\n > * {\n @include make-col-ready();\n }\n }\n}\n\n@if $enable-cssgrid {\n .grid {\n display: grid;\n grid-template-rows: repeat(var(--#{$prefix}rows, 1), 1fr);\n grid-template-columns: repeat(var(--#{$prefix}columns, #{$grid-columns}), 1fr);\n gap: var(--#{$prefix}gap, #{$grid-gutter-width});\n\n @include make-cssgrid();\n }\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n@if $enable-grid-classes {\n @include make-grid-columns();\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n@mixin make-row($gutter: $grid-gutter-width) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n // TODO: Revisit calc order after https://github.com/react-bootstrap/react-bootstrap/issues/6039 is fixed\n margin-top: calc(-1 * var(--#{$prefix}gutter-y)); // stylelint-disable-line function-disallowed-list\n margin-right: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n margin-left: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n}\n\n@mixin make-col-ready() {\n // Add box sizing if only the grid is loaded\n box-sizing: if(variable-exists(include-column-box-sizing) and $include-column-box-sizing, border-box, null);\n // Prevent columns from becoming too narrow when at smaller grid tiers by\n // always setting `width: 100%;`. This works because we set the width\n // later on to override this initial width.\n flex-shrink: 0;\n width: 100%;\n max-width: 100%; // Prevent `.col-auto`, `.col` (& responsive variants) from breaking out the grid\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-top: var(--#{$prefix}gutter-y);\n}\n\n@mixin make-col($size: false, $columns: $grid-columns) {\n @if $size {\n flex: 0 0 auto;\n width: percentage(divide($size, $columns));\n\n } @else {\n flex: 1 1 0;\n max-width: 100%;\n }\n}\n\n@mixin make-col-auto() {\n flex: 0 0 auto;\n width: auto;\n}\n\n@mixin make-col-offset($size, $columns: $grid-columns) {\n $num: divide($size, $columns);\n margin-left: if($num == 0, 0, percentage($num));\n}\n\n// Row columns\n//\n// Specify on a parent element(e.g., .row) to force immediate children into NN\n// number of columns. Supports wrapping to new lines, but does not do a Masonry\n// style grid.\n@mixin row-cols($count) {\n > * {\n flex: 0 0 auto;\n width: percentage(divide(1, $count));\n }\n}\n\n// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `$grid-columns`.\n\n@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n // Provide basic `.col-{bp}` classes for equal-width flexbox columns\n .col#{$infix} {\n flex: 1 0 0%; // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4\n }\n\n .row-cols#{$infix}-auto > * {\n @include make-col-auto();\n }\n\n @if $grid-row-columns > 0 {\n @for $i from 1 through $grid-row-columns {\n .row-cols#{$infix}-#{$i} {\n @include row-cols($i);\n }\n }\n }\n\n .col#{$infix}-auto {\n @include make-col-auto();\n }\n\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @include make-col($i, $columns);\n }\n }\n\n // `$columns - 1` because offsetting by the width of an entire row isn't possible\n @for $i from 0 through ($columns - 1) {\n @if not ($infix == \"\" and $i == 0) { // Avoid emitting useless .offset-0\n .offset#{$infix}-#{$i} {\n @include make-col-offset($i, $columns);\n }\n }\n }\n }\n\n // Gutters\n //\n // Make use of `.g-*`, `.gx-*` or `.gy-*` utilities to change spacing between the columns.\n @each $key, $value in $gutters {\n .g#{$infix}-#{$key},\n .gx#{$infix}-#{$key} {\n --#{$prefix}gutter-x: #{$value};\n }\n\n .g#{$infix}-#{$key},\n .gy#{$infix}-#{$key} {\n --#{$prefix}gutter-y: #{$value};\n }\n }\n }\n }\n}\n\n@mixin make-cssgrid($columns: $grid-columns, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .g-col#{$infix}-#{$i} {\n grid-column: auto / span $i;\n }\n }\n\n // Start with `1` because `0` is an invalid value.\n // Ends with `$columns - 1` because offsetting by the width of an entire row isn't possible.\n @for $i from 1 through ($columns - 1) {\n .g-start#{$infix}-#{$i} {\n grid-column-start: $i;\n }\n }\n }\n }\n }\n}\n","// Utility generator\n// Used to generate utilities & print utilities\n@mixin generate-utility($utility, $infix: \"\", $is-rfs-media-query: false) {\n $values: map-get($utility, values);\n\n // If the values are a list or string, convert it into a map\n @if type-of($values) == \"string\" or type-of(nth($values, 1)) != \"list\" {\n $values: zip($values, $values);\n }\n\n @each $key, $value in $values {\n $properties: map-get($utility, property);\n\n // Multiple properties are possible, for example with vertical or horizontal margins or paddings\n @if type-of($properties) == \"string\" {\n $properties: append((), $properties);\n }\n\n // Use custom class if present\n $property-class: if(map-has-key($utility, class), map-get($utility, class), nth($properties, 1));\n $property-class: if($property-class == null, \"\", $property-class);\n\n // Use custom CSS variable name if present, otherwise default to `class`\n $css-variable-name: if(map-has-key($utility, css-variable-name), map-get($utility, css-variable-name), map-get($utility, class));\n\n // State params to generate pseudo-classes\n $state: if(map-has-key($utility, state), map-get($utility, state), ());\n\n $infix: if($property-class == \"\" and str-slice($infix, 1, 1) == \"-\", str-slice($infix, 2), $infix);\n\n // Don't prefix if value key is null (e.g. with shadow class)\n $property-class-modifier: if($key, if($property-class == \"\" and $infix == \"\", \"\", \"-\") + $key, \"\");\n\n @if map-get($utility, rfs) {\n // Inside the media query\n @if $is-rfs-media-query {\n $val: rfs-value($value);\n\n // Do not render anything if fluid and non fluid values are the same\n $value: if($val == rfs-fluid-value($value), null, $val);\n }\n @else {\n $value: rfs-fluid-value($value);\n }\n }\n\n $is-css-var: map-get($utility, css-var);\n $is-local-vars: map-get($utility, local-vars);\n $is-rtl: map-get($utility, rtl);\n\n @if $value != null {\n @if $is-rtl == false {\n /* rtl:begin:remove */\n }\n\n @if $is-css-var {\n .#{$property-class + $infix + $property-class-modifier} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n }\n } @else {\n .#{$property-class + $infix + $property-class-modifier} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n }\n }\n\n @if $is-rtl == false {\n /* rtl:end:remove */\n }\n }\n }\n}\n","// Loop over each breakpoint\n@each $breakpoint in map-keys($grid-breakpoints) {\n\n // Generate media query if needed\n @include media-breakpoint-up($breakpoint) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix);\n }\n }\n }\n}\n\n// RFS rescaling\n@media (min-width: $rfs-mq-value) {\n @each $breakpoint in map-keys($grid-breakpoints) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n @if (map-get($grid-breakpoints, $breakpoint) < $rfs-breakpoint) {\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and map-get($utility, rfs) and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix, true);\n }\n }\n }\n }\n}\n\n\n// Print utilities\n@media print {\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Then check if the utility needs print styles\n @if type-of($utility) == \"map\" and map-get($utility, print) == true {\n @include generate-utility($utility, \"-print\");\n }\n }\n}\n"]} \ No newline at end of file diff --git a/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css new file mode 100644 index 0000000..90af5ff --- /dev/null +++ b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap Grid v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-left:calc(var(--bs-gutter-x) * .5);padding-right:calc(var(--bs-gutter-x) * .5);margin-left:auto;margin-right:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-left:calc(-.5 * var(--bs-gutter-x));margin-right:calc(-.5 * var(--bs-gutter-x))}.row>*{box-sizing:border-box;flex-shrink:0;width:100%;max-width:100%;padding-left:calc(var(--bs-gutter-x) * .5);padding-right:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-right:8.33333333%}.offset-2{margin-right:16.66666667%}.offset-3{margin-right:25%}.offset-4{margin-right:33.33333333%}.offset-5{margin-right:41.66666667%}.offset-6{margin-right:50%}.offset-7{margin-right:58.33333333%}.offset-8{margin-right:66.66666667%}.offset-9{margin-right:75%}.offset-10{margin-right:83.33333333%}.offset-11{margin-right:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-right:0}.offset-sm-1{margin-right:8.33333333%}.offset-sm-2{margin-right:16.66666667%}.offset-sm-3{margin-right:25%}.offset-sm-4{margin-right:33.33333333%}.offset-sm-5{margin-right:41.66666667%}.offset-sm-6{margin-right:50%}.offset-sm-7{margin-right:58.33333333%}.offset-sm-8{margin-right:66.66666667%}.offset-sm-9{margin-right:75%}.offset-sm-10{margin-right:83.33333333%}.offset-sm-11{margin-right:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-right:0}.offset-md-1{margin-right:8.33333333%}.offset-md-2{margin-right:16.66666667%}.offset-md-3{margin-right:25%}.offset-md-4{margin-right:33.33333333%}.offset-md-5{margin-right:41.66666667%}.offset-md-6{margin-right:50%}.offset-md-7{margin-right:58.33333333%}.offset-md-8{margin-right:66.66666667%}.offset-md-9{margin-right:75%}.offset-md-10{margin-right:83.33333333%}.offset-md-11{margin-right:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-right:0}.offset-lg-1{margin-right:8.33333333%}.offset-lg-2{margin-right:16.66666667%}.offset-lg-3{margin-right:25%}.offset-lg-4{margin-right:33.33333333%}.offset-lg-5{margin-right:41.66666667%}.offset-lg-6{margin-right:50%}.offset-lg-7{margin-right:58.33333333%}.offset-lg-8{margin-right:66.66666667%}.offset-lg-9{margin-right:75%}.offset-lg-10{margin-right:83.33333333%}.offset-lg-11{margin-right:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-right:0}.offset-xl-1{margin-right:8.33333333%}.offset-xl-2{margin-right:16.66666667%}.offset-xl-3{margin-right:25%}.offset-xl-4{margin-right:33.33333333%}.offset-xl-5{margin-right:41.66666667%}.offset-xl-6{margin-right:50%}.offset-xl-7{margin-right:58.33333333%}.offset-xl-8{margin-right:66.66666667%}.offset-xl-9{margin-right:75%}.offset-xl-10{margin-right:83.33333333%}.offset-xl-11{margin-right:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-right:0}.offset-xxl-1{margin-right:8.33333333%}.offset-xxl-2{margin-right:16.66666667%}.offset-xxl-3{margin-right:25%}.offset-xxl-4{margin-right:33.33333333%}.offset-xxl-5{margin-right:41.66666667%}.offset-xxl-6{margin-right:50%}.offset-xxl-7{margin-right:58.33333333%}.offset-xxl-8{margin-right:66.66666667%}.offset-xxl-9{margin-right:75%}.offset-xxl-10{margin-right:83.33333333%}.offset-xxl-11{margin-right:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-left:0!important;margin-right:0!important}.mx-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-3{margin-left:1rem!important;margin-right:1rem!important}.mx-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-5{margin-left:3rem!important;margin-right:3rem!important}.mx-auto{margin-left:auto!important;margin-right:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-left:0!important}.me-1{margin-left:.25rem!important}.me-2{margin-left:.5rem!important}.me-3{margin-left:1rem!important}.me-4{margin-left:1.5rem!important}.me-5{margin-left:3rem!important}.me-auto{margin-left:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-right:0!important}.ms-1{margin-right:.25rem!important}.ms-2{margin-right:.5rem!important}.ms-3{margin-right:1rem!important}.ms-4{margin-right:1.5rem!important}.ms-5{margin-right:3rem!important}.ms-auto{margin-right:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-left:0!important;padding-right:0!important}.px-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-3{padding-left:1rem!important;padding-right:1rem!important}.px-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-5{padding-left:3rem!important;padding-right:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-left:0!important}.pe-1{padding-left:.25rem!important}.pe-2{padding-left:.5rem!important}.pe-3{padding-left:1rem!important}.pe-4{padding-left:1.5rem!important}.pe-5{padding-left:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-right:0!important}.ps-1{padding-right:.25rem!important}.ps-2{padding-right:.5rem!important}.ps-3{padding-right:1rem!important}.ps-4{padding-right:1.5rem!important}.ps-5{padding-right:3rem!important}@media (min-width:576px){.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-left:0!important;margin-right:0!important}.mx-sm-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-sm-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-sm-3{margin-left:1rem!important;margin-right:1rem!important}.mx-sm-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-sm-5{margin-left:3rem!important;margin-right:3rem!important}.mx-sm-auto{margin-left:auto!important;margin-right:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-left:0!important}.me-sm-1{margin-left:.25rem!important}.me-sm-2{margin-left:.5rem!important}.me-sm-3{margin-left:1rem!important}.me-sm-4{margin-left:1.5rem!important}.me-sm-5{margin-left:3rem!important}.me-sm-auto{margin-left:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-right:0!important}.ms-sm-1{margin-right:.25rem!important}.ms-sm-2{margin-right:.5rem!important}.ms-sm-3{margin-right:1rem!important}.ms-sm-4{margin-right:1.5rem!important}.ms-sm-5{margin-right:3rem!important}.ms-sm-auto{margin-right:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-left:0!important;padding-right:0!important}.px-sm-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-sm-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-sm-3{padding-left:1rem!important;padding-right:1rem!important}.px-sm-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-sm-5{padding-left:3rem!important;padding-right:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-left:0!important}.pe-sm-1{padding-left:.25rem!important}.pe-sm-2{padding-left:.5rem!important}.pe-sm-3{padding-left:1rem!important}.pe-sm-4{padding-left:1.5rem!important}.pe-sm-5{padding-left:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-right:0!important}.ps-sm-1{padding-right:.25rem!important}.ps-sm-2{padding-right:.5rem!important}.ps-sm-3{padding-right:1rem!important}.ps-sm-4{padding-right:1.5rem!important}.ps-sm-5{padding-right:3rem!important}}@media (min-width:768px){.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-left:0!important;margin-right:0!important}.mx-md-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-md-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-md-3{margin-left:1rem!important;margin-right:1rem!important}.mx-md-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-md-5{margin-left:3rem!important;margin-right:3rem!important}.mx-md-auto{margin-left:auto!important;margin-right:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-left:0!important}.me-md-1{margin-left:.25rem!important}.me-md-2{margin-left:.5rem!important}.me-md-3{margin-left:1rem!important}.me-md-4{margin-left:1.5rem!important}.me-md-5{margin-left:3rem!important}.me-md-auto{margin-left:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-right:0!important}.ms-md-1{margin-right:.25rem!important}.ms-md-2{margin-right:.5rem!important}.ms-md-3{margin-right:1rem!important}.ms-md-4{margin-right:1.5rem!important}.ms-md-5{margin-right:3rem!important}.ms-md-auto{margin-right:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-left:0!important;padding-right:0!important}.px-md-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-md-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-md-3{padding-left:1rem!important;padding-right:1rem!important}.px-md-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-md-5{padding-left:3rem!important;padding-right:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-left:0!important}.pe-md-1{padding-left:.25rem!important}.pe-md-2{padding-left:.5rem!important}.pe-md-3{padding-left:1rem!important}.pe-md-4{padding-left:1.5rem!important}.pe-md-5{padding-left:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-right:0!important}.ps-md-1{padding-right:.25rem!important}.ps-md-2{padding-right:.5rem!important}.ps-md-3{padding-right:1rem!important}.ps-md-4{padding-right:1.5rem!important}.ps-md-5{padding-right:3rem!important}}@media (min-width:992px){.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-left:0!important;margin-right:0!important}.mx-lg-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-lg-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-lg-3{margin-left:1rem!important;margin-right:1rem!important}.mx-lg-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-lg-5{margin-left:3rem!important;margin-right:3rem!important}.mx-lg-auto{margin-left:auto!important;margin-right:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-left:0!important}.me-lg-1{margin-left:.25rem!important}.me-lg-2{margin-left:.5rem!important}.me-lg-3{margin-left:1rem!important}.me-lg-4{margin-left:1.5rem!important}.me-lg-5{margin-left:3rem!important}.me-lg-auto{margin-left:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-right:0!important}.ms-lg-1{margin-right:.25rem!important}.ms-lg-2{margin-right:.5rem!important}.ms-lg-3{margin-right:1rem!important}.ms-lg-4{margin-right:1.5rem!important}.ms-lg-5{margin-right:3rem!important}.ms-lg-auto{margin-right:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-left:0!important;padding-right:0!important}.px-lg-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-lg-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-lg-3{padding-left:1rem!important;padding-right:1rem!important}.px-lg-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-lg-5{padding-left:3rem!important;padding-right:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-left:0!important}.pe-lg-1{padding-left:.25rem!important}.pe-lg-2{padding-left:.5rem!important}.pe-lg-3{padding-left:1rem!important}.pe-lg-4{padding-left:1.5rem!important}.pe-lg-5{padding-left:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-right:0!important}.ps-lg-1{padding-right:.25rem!important}.ps-lg-2{padding-right:.5rem!important}.ps-lg-3{padding-right:1rem!important}.ps-lg-4{padding-right:1.5rem!important}.ps-lg-5{padding-right:3rem!important}}@media (min-width:1200px){.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-left:0!important;margin-right:0!important}.mx-xl-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-xl-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-xl-3{margin-left:1rem!important;margin-right:1rem!important}.mx-xl-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-xl-5{margin-left:3rem!important;margin-right:3rem!important}.mx-xl-auto{margin-left:auto!important;margin-right:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-left:0!important}.me-xl-1{margin-left:.25rem!important}.me-xl-2{margin-left:.5rem!important}.me-xl-3{margin-left:1rem!important}.me-xl-4{margin-left:1.5rem!important}.me-xl-5{margin-left:3rem!important}.me-xl-auto{margin-left:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-right:0!important}.ms-xl-1{margin-right:.25rem!important}.ms-xl-2{margin-right:.5rem!important}.ms-xl-3{margin-right:1rem!important}.ms-xl-4{margin-right:1.5rem!important}.ms-xl-5{margin-right:3rem!important}.ms-xl-auto{margin-right:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-left:0!important;padding-right:0!important}.px-xl-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-xl-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-xl-3{padding-left:1rem!important;padding-right:1rem!important}.px-xl-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-xl-5{padding-left:3rem!important;padding-right:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-left:0!important}.pe-xl-1{padding-left:.25rem!important}.pe-xl-2{padding-left:.5rem!important}.pe-xl-3{padding-left:1rem!important}.pe-xl-4{padding-left:1.5rem!important}.pe-xl-5{padding-left:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-right:0!important}.ps-xl-1{padding-right:.25rem!important}.ps-xl-2{padding-right:.5rem!important}.ps-xl-3{padding-right:1rem!important}.ps-xl-4{padding-right:1.5rem!important}.ps-xl-5{padding-right:3rem!important}}@media (min-width:1400px){.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-left:0!important;margin-right:0!important}.mx-xxl-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-xxl-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-xxl-3{margin-left:1rem!important;margin-right:1rem!important}.mx-xxl-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-xxl-5{margin-left:3rem!important;margin-right:3rem!important}.mx-xxl-auto{margin-left:auto!important;margin-right:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-left:0!important}.me-xxl-1{margin-left:.25rem!important}.me-xxl-2{margin-left:.5rem!important}.me-xxl-3{margin-left:1rem!important}.me-xxl-4{margin-left:1.5rem!important}.me-xxl-5{margin-left:3rem!important}.me-xxl-auto{margin-left:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-right:0!important}.ms-xxl-1{margin-right:.25rem!important}.ms-xxl-2{margin-right:.5rem!important}.ms-xxl-3{margin-right:1rem!important}.ms-xxl-4{margin-right:1.5rem!important}.ms-xxl-5{margin-right:3rem!important}.ms-xxl-auto{margin-right:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-left:0!important;padding-right:0!important}.px-xxl-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-xxl-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-xxl-3{padding-left:1rem!important;padding-right:1rem!important}.px-xxl-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-xxl-5{padding-left:3rem!important;padding-right:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-left:0!important}.pe-xxl-1{padding-left:.25rem!important}.pe-xxl-2{padding-left:.5rem!important}.pe-xxl-3{padding-left:1rem!important}.pe-xxl-4{padding-left:1.5rem!important}.pe-xxl-5{padding-left:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-right:0!important}.ps-xxl-1{padding-right:.25rem!important}.ps-xxl-2{padding-right:.5rem!important}.ps-xxl-3{padding-right:1rem!important}.ps-xxl-4{padding-right:1.5rem!important}.ps-xxl-5{padding-right:3rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap-grid.rtl.min.css.map */ \ No newline at end of file diff --git a/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map new file mode 100644 index 0000000..1c926af --- /dev/null +++ b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_containers.scss","dist/css/bootstrap-grid.rtl.css","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"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","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n","// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n@if $enable-container-classes {\n // Single container class with breakpoint max-widths\n .container,\n // 100% wide container at all breakpoints\n .container-fluid {\n @include make-container();\n }\n\n // Responsive containers that are 100% wide until a breakpoint\n @each $breakpoint, $container-max-width in $container-max-widths {\n .container-#{$breakpoint} {\n @extend .container-fluid;\n }\n\n @include media-breakpoint-up($breakpoint, $grid-breakpoints) {\n %responsive-container-#{$breakpoint} {\n max-width: $container-max-width;\n }\n\n // Extend each breakpoint which is smaller or equal to the current breakpoint\n $extend-breakpoint: true;\n\n @each $name, $width in $grid-breakpoints {\n @if ($extend-breakpoint) {\n .container#{breakpoint-infix($name, $grid-breakpoints)} {\n @extend %responsive-container-#{$breakpoint};\n }\n\n // Once the current breakpoint is reached, stop extending\n @if ($breakpoint == $name) {\n $extend-breakpoint: false;\n }\n }\n }\n }\n }\n}\n","/*!\n * Bootstrap Grid v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n.container,\n.container-fluid,\n.container-xxl,\n.container-xl,\n.container-lg,\n.container-md,\n.container-sm {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n width: 100%;\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n margin-left: auto;\n margin-right: auto;\n}\n\n@media (min-width: 576px) {\n .container-sm, .container {\n max-width: 540px;\n }\n}\n@media (min-width: 768px) {\n .container-md, .container-sm, .container {\n max-width: 720px;\n }\n}\n@media (min-width: 992px) {\n .container-lg, .container-md, .container-sm, .container {\n max-width: 960px;\n }\n}\n@media (min-width: 1200px) {\n .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1140px;\n }\n}\n@media (min-width: 1400px) {\n .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1320px;\n }\n}\n:root {\n --bs-breakpoint-xs: 0;\n --bs-breakpoint-sm: 576px;\n --bs-breakpoint-md: 768px;\n --bs-breakpoint-lg: 992px;\n --bs-breakpoint-xl: 1200px;\n --bs-breakpoint-xxl: 1400px;\n}\n\n.row {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n margin-top: calc(-1 * var(--bs-gutter-y));\n margin-left: calc(-0.5 * var(--bs-gutter-x));\n margin-right: calc(-0.5 * var(--bs-gutter-x));\n}\n.row > * {\n box-sizing: border-box;\n flex-shrink: 0;\n width: 100%;\n max-width: 100%;\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n margin-top: var(--bs-gutter-y);\n}\n\n.col {\n flex: 1 0 0%;\n}\n\n.row-cols-auto > * {\n flex: 0 0 auto;\n width: auto;\n}\n\n.row-cols-1 > * {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.row-cols-2 > * {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.row-cols-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.row-cols-4 > * {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.row-cols-5 > * {\n flex: 0 0 auto;\n width: 20%;\n}\n\n.row-cols-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-auto {\n flex: 0 0 auto;\n width: auto;\n}\n\n.col-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n}\n\n.col-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-3 {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.col-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.col-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n}\n\n.col-6 {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.col-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n}\n\n.col-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n}\n\n.col-9 {\n flex: 0 0 auto;\n width: 75%;\n}\n\n.col-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n}\n\n.col-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n}\n\n.col-12 {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.offset-1 {\n margin-right: 8.33333333%;\n}\n\n.offset-2 {\n margin-right: 16.66666667%;\n}\n\n.offset-3 {\n margin-right: 25%;\n}\n\n.offset-4 {\n margin-right: 33.33333333%;\n}\n\n.offset-5 {\n margin-right: 41.66666667%;\n}\n\n.offset-6 {\n margin-right: 50%;\n}\n\n.offset-7 {\n margin-right: 58.33333333%;\n}\n\n.offset-8 {\n margin-right: 66.66666667%;\n}\n\n.offset-9 {\n margin-right: 75%;\n}\n\n.offset-10 {\n margin-right: 83.33333333%;\n}\n\n.offset-11 {\n margin-right: 91.66666667%;\n}\n\n.g-0,\n.gx-0 {\n --bs-gutter-x: 0;\n}\n\n.g-0,\n.gy-0 {\n --bs-gutter-y: 0;\n}\n\n.g-1,\n.gx-1 {\n --bs-gutter-x: 0.25rem;\n}\n\n.g-1,\n.gy-1 {\n --bs-gutter-y: 0.25rem;\n}\n\n.g-2,\n.gx-2 {\n --bs-gutter-x: 0.5rem;\n}\n\n.g-2,\n.gy-2 {\n --bs-gutter-y: 0.5rem;\n}\n\n.g-3,\n.gx-3 {\n --bs-gutter-x: 1rem;\n}\n\n.g-3,\n.gy-3 {\n --bs-gutter-y: 1rem;\n}\n\n.g-4,\n.gx-4 {\n --bs-gutter-x: 1.5rem;\n}\n\n.g-4,\n.gy-4 {\n --bs-gutter-y: 1.5rem;\n}\n\n.g-5,\n.gx-5 {\n --bs-gutter-x: 3rem;\n}\n\n.g-5,\n.gy-5 {\n --bs-gutter-y: 3rem;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n flex: 1 0 0%;\n }\n .row-cols-sm-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-sm-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-sm-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-sm-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-sm-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-sm-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-sm-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-sm-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-sm-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-sm-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-sm-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-sm-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-sm-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-sm-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-sm-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-sm-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-sm-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-sm-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-sm-0 {\n margin-right: 0;\n }\n .offset-sm-1 {\n margin-right: 8.33333333%;\n }\n .offset-sm-2 {\n margin-right: 16.66666667%;\n }\n .offset-sm-3 {\n margin-right: 25%;\n }\n .offset-sm-4 {\n margin-right: 33.33333333%;\n }\n .offset-sm-5 {\n margin-right: 41.66666667%;\n }\n .offset-sm-6 {\n margin-right: 50%;\n }\n .offset-sm-7 {\n margin-right: 58.33333333%;\n }\n .offset-sm-8 {\n margin-right: 66.66666667%;\n }\n .offset-sm-9 {\n margin-right: 75%;\n }\n .offset-sm-10 {\n margin-right: 83.33333333%;\n }\n .offset-sm-11 {\n margin-right: 91.66666667%;\n }\n .g-sm-0,\n .gx-sm-0 {\n --bs-gutter-x: 0;\n }\n .g-sm-0,\n .gy-sm-0 {\n --bs-gutter-y: 0;\n }\n .g-sm-1,\n .gx-sm-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-sm-1,\n .gy-sm-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-sm-2,\n .gx-sm-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-sm-2,\n .gy-sm-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-sm-3,\n .gx-sm-3 {\n --bs-gutter-x: 1rem;\n }\n .g-sm-3,\n .gy-sm-3 {\n --bs-gutter-y: 1rem;\n }\n .g-sm-4,\n .gx-sm-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-sm-4,\n .gy-sm-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-sm-5,\n .gx-sm-5 {\n --bs-gutter-x: 3rem;\n }\n .g-sm-5,\n .gy-sm-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 768px) {\n .col-md {\n flex: 1 0 0%;\n }\n .row-cols-md-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-md-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-md-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-md-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-md-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-md-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-md-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-md-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-md-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-md-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-md-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-md-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-md-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-md-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-md-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-md-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-md-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-md-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-md-0 {\n margin-right: 0;\n }\n .offset-md-1 {\n margin-right: 8.33333333%;\n }\n .offset-md-2 {\n margin-right: 16.66666667%;\n }\n .offset-md-3 {\n margin-right: 25%;\n }\n .offset-md-4 {\n margin-right: 33.33333333%;\n }\n .offset-md-5 {\n margin-right: 41.66666667%;\n }\n .offset-md-6 {\n margin-right: 50%;\n }\n .offset-md-7 {\n margin-right: 58.33333333%;\n }\n .offset-md-8 {\n margin-right: 66.66666667%;\n }\n .offset-md-9 {\n margin-right: 75%;\n }\n .offset-md-10 {\n margin-right: 83.33333333%;\n }\n .offset-md-11 {\n margin-right: 91.66666667%;\n }\n .g-md-0,\n .gx-md-0 {\n --bs-gutter-x: 0;\n }\n .g-md-0,\n .gy-md-0 {\n --bs-gutter-y: 0;\n }\n .g-md-1,\n .gx-md-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-md-1,\n .gy-md-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-md-2,\n .gx-md-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-md-2,\n .gy-md-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-md-3,\n .gx-md-3 {\n --bs-gutter-x: 1rem;\n }\n .g-md-3,\n .gy-md-3 {\n --bs-gutter-y: 1rem;\n }\n .g-md-4,\n .gx-md-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-md-4,\n .gy-md-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-md-5,\n .gx-md-5 {\n --bs-gutter-x: 3rem;\n }\n .g-md-5,\n .gy-md-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 992px) {\n .col-lg {\n flex: 1 0 0%;\n }\n .row-cols-lg-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-lg-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-lg-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-lg-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-lg-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-lg-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-lg-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-lg-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-lg-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-lg-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-lg-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-lg-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-lg-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-lg-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-lg-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-lg-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-lg-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-lg-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-lg-0 {\n margin-right: 0;\n }\n .offset-lg-1 {\n margin-right: 8.33333333%;\n }\n .offset-lg-2 {\n margin-right: 16.66666667%;\n }\n .offset-lg-3 {\n margin-right: 25%;\n }\n .offset-lg-4 {\n margin-right: 33.33333333%;\n }\n .offset-lg-5 {\n margin-right: 41.66666667%;\n }\n .offset-lg-6 {\n margin-right: 50%;\n }\n .offset-lg-7 {\n margin-right: 58.33333333%;\n }\n .offset-lg-8 {\n margin-right: 66.66666667%;\n }\n .offset-lg-9 {\n margin-right: 75%;\n }\n .offset-lg-10 {\n margin-right: 83.33333333%;\n }\n .offset-lg-11 {\n margin-right: 91.66666667%;\n }\n .g-lg-0,\n .gx-lg-0 {\n --bs-gutter-x: 0;\n }\n .g-lg-0,\n .gy-lg-0 {\n --bs-gutter-y: 0;\n }\n .g-lg-1,\n .gx-lg-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-lg-1,\n .gy-lg-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-lg-2,\n .gx-lg-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-lg-2,\n .gy-lg-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-lg-3,\n .gx-lg-3 {\n --bs-gutter-x: 1rem;\n }\n .g-lg-3,\n .gy-lg-3 {\n --bs-gutter-y: 1rem;\n }\n .g-lg-4,\n .gx-lg-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-lg-4,\n .gy-lg-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-lg-5,\n .gx-lg-5 {\n --bs-gutter-x: 3rem;\n }\n .g-lg-5,\n .gy-lg-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1200px) {\n .col-xl {\n flex: 1 0 0%;\n }\n .row-cols-xl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xl-0 {\n margin-right: 0;\n }\n .offset-xl-1 {\n margin-right: 8.33333333%;\n }\n .offset-xl-2 {\n margin-right: 16.66666667%;\n }\n .offset-xl-3 {\n margin-right: 25%;\n }\n .offset-xl-4 {\n margin-right: 33.33333333%;\n }\n .offset-xl-5 {\n margin-right: 41.66666667%;\n }\n .offset-xl-6 {\n margin-right: 50%;\n }\n .offset-xl-7 {\n margin-right: 58.33333333%;\n }\n .offset-xl-8 {\n margin-right: 66.66666667%;\n }\n .offset-xl-9 {\n margin-right: 75%;\n }\n .offset-xl-10 {\n margin-right: 83.33333333%;\n }\n .offset-xl-11 {\n margin-right: 91.66666667%;\n }\n .g-xl-0,\n .gx-xl-0 {\n --bs-gutter-x: 0;\n }\n .g-xl-0,\n .gy-xl-0 {\n --bs-gutter-y: 0;\n }\n .g-xl-1,\n .gx-xl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xl-1,\n .gy-xl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xl-2,\n .gx-xl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xl-2,\n .gy-xl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xl-3,\n .gx-xl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xl-3,\n .gy-xl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xl-4,\n .gx-xl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xl-4,\n .gy-xl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xl-5,\n .gx-xl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xl-5,\n .gy-xl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1400px) {\n .col-xxl {\n flex: 1 0 0%;\n }\n .row-cols-xxl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xxl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xxl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xxl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xxl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xxl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xxl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xxl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xxl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xxl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xxl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xxl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xxl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xxl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xxl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xxl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xxl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xxl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xxl-0 {\n margin-right: 0;\n }\n .offset-xxl-1 {\n margin-right: 8.33333333%;\n }\n .offset-xxl-2 {\n margin-right: 16.66666667%;\n }\n .offset-xxl-3 {\n margin-right: 25%;\n }\n .offset-xxl-4 {\n margin-right: 33.33333333%;\n }\n .offset-xxl-5 {\n margin-right: 41.66666667%;\n }\n .offset-xxl-6 {\n margin-right: 50%;\n }\n .offset-xxl-7 {\n margin-right: 58.33333333%;\n }\n .offset-xxl-8 {\n margin-right: 66.66666667%;\n }\n .offset-xxl-9 {\n margin-right: 75%;\n }\n .offset-xxl-10 {\n margin-right: 83.33333333%;\n }\n .offset-xxl-11 {\n margin-right: 91.66666667%;\n }\n .g-xxl-0,\n .gx-xxl-0 {\n --bs-gutter-x: 0;\n }\n .g-xxl-0,\n .gy-xxl-0 {\n --bs-gutter-y: 0;\n }\n .g-xxl-1,\n .gx-xxl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xxl-1,\n .gy-xxl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xxl-2,\n .gx-xxl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xxl-2,\n .gy-xxl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xxl-3,\n .gx-xxl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xxl-3,\n .gy-xxl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xxl-4,\n .gx-xxl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xxl-4,\n .gy-xxl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xxl-5,\n .gx-xxl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xxl-5,\n .gy-xxl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-grid {\n display: grid !important;\n}\n\n.d-inline-grid {\n display: inline-grid !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: flex !important;\n}\n\n.d-inline-flex {\n display: inline-flex !important;\n}\n\n.d-none {\n display: none !important;\n}\n\n.flex-fill {\n flex: 1 1 auto !important;\n}\n\n.flex-row {\n flex-direction: row !important;\n}\n\n.flex-column {\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n flex-direction: column-reverse !important;\n}\n\n.flex-grow-0 {\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n flex-shrink: 1 !important;\n}\n\n.flex-wrap {\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n}\n\n.justify-content-start {\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n justify-content: center !important;\n}\n\n.justify-content-between {\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n justify-content: space-around !important;\n}\n\n.justify-content-evenly {\n justify-content: space-evenly !important;\n}\n\n.align-items-start {\n align-items: flex-start !important;\n}\n\n.align-items-end {\n align-items: flex-end !important;\n}\n\n.align-items-center {\n align-items: center !important;\n}\n\n.align-items-baseline {\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n align-items: stretch !important;\n}\n\n.align-content-start {\n align-content: flex-start !important;\n}\n\n.align-content-end {\n align-content: flex-end !important;\n}\n\n.align-content-center {\n align-content: center !important;\n}\n\n.align-content-between {\n align-content: space-between !important;\n}\n\n.align-content-around {\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n align-content: stretch !important;\n}\n\n.align-self-auto {\n align-self: auto !important;\n}\n\n.align-self-start {\n align-self: flex-start !important;\n}\n\n.align-self-end {\n align-self: flex-end !important;\n}\n\n.align-self-center {\n align-self: center !important;\n}\n\n.align-self-baseline {\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n align-self: stretch !important;\n}\n\n.order-first {\n order: -1 !important;\n}\n\n.order-0 {\n order: 0 !important;\n}\n\n.order-1 {\n order: 1 !important;\n}\n\n.order-2 {\n order: 2 !important;\n}\n\n.order-3 {\n order: 3 !important;\n}\n\n.order-4 {\n order: 4 !important;\n}\n\n.order-5 {\n order: 5 !important;\n}\n\n.order-last {\n order: 6 !important;\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mx-0 {\n margin-left: 0 !important;\n margin-right: 0 !important;\n}\n\n.mx-1 {\n margin-left: 0.25rem !important;\n margin-right: 0.25rem !important;\n}\n\n.mx-2 {\n margin-left: 0.5rem !important;\n margin-right: 0.5rem !important;\n}\n\n.mx-3 {\n margin-left: 1rem !important;\n margin-right: 1rem !important;\n}\n\n.mx-4 {\n margin-left: 1.5rem !important;\n margin-right: 1.5rem !important;\n}\n\n.mx-5 {\n margin-left: 3rem !important;\n margin-right: 3rem !important;\n}\n\n.mx-auto {\n margin-left: auto !important;\n margin-right: auto !important;\n}\n\n.my-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n}\n\n.my-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n}\n\n.my-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n}\n\n.my-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n}\n\n.my-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n}\n\n.my-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n}\n\n.my-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n}\n\n.mt-0 {\n margin-top: 0 !important;\n}\n\n.mt-1 {\n margin-top: 0.25rem !important;\n}\n\n.mt-2 {\n margin-top: 0.5rem !important;\n}\n\n.mt-3 {\n margin-top: 1rem !important;\n}\n\n.mt-4 {\n margin-top: 1.5rem !important;\n}\n\n.mt-5 {\n margin-top: 3rem !important;\n}\n\n.mt-auto {\n margin-top: auto !important;\n}\n\n.me-0 {\n margin-left: 0 !important;\n}\n\n.me-1 {\n margin-left: 0.25rem !important;\n}\n\n.me-2 {\n margin-left: 0.5rem !important;\n}\n\n.me-3 {\n margin-left: 1rem !important;\n}\n\n.me-4 {\n margin-left: 1.5rem !important;\n}\n\n.me-5 {\n margin-left: 3rem !important;\n}\n\n.me-auto {\n margin-left: auto !important;\n}\n\n.mb-0 {\n margin-bottom: 0 !important;\n}\n\n.mb-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.mb-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.mb-3 {\n margin-bottom: 1rem !important;\n}\n\n.mb-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.mb-5 {\n margin-bottom: 3rem !important;\n}\n\n.mb-auto {\n margin-bottom: auto !important;\n}\n\n.ms-0 {\n margin-right: 0 !important;\n}\n\n.ms-1 {\n margin-right: 0.25rem !important;\n}\n\n.ms-2 {\n margin-right: 0.5rem !important;\n}\n\n.ms-3 {\n margin-right: 1rem !important;\n}\n\n.ms-4 {\n margin-right: 1.5rem !important;\n}\n\n.ms-5 {\n margin-right: 3rem !important;\n}\n\n.ms-auto {\n margin-right: auto !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.px-0 {\n padding-left: 0 !important;\n padding-right: 0 !important;\n}\n\n.px-1 {\n padding-left: 0.25rem !important;\n padding-right: 0.25rem !important;\n}\n\n.px-2 {\n padding-left: 0.5rem !important;\n padding-right: 0.5rem !important;\n}\n\n.px-3 {\n padding-left: 1rem !important;\n padding-right: 1rem !important;\n}\n\n.px-4 {\n padding-left: 1.5rem !important;\n padding-right: 1.5rem !important;\n}\n\n.px-5 {\n padding-left: 3rem !important;\n padding-right: 3rem !important;\n}\n\n.py-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n}\n\n.py-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n}\n\n.py-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n}\n\n.py-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n}\n\n.py-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n}\n\n.py-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n}\n\n.pt-0 {\n padding-top: 0 !important;\n}\n\n.pt-1 {\n padding-top: 0.25rem !important;\n}\n\n.pt-2 {\n padding-top: 0.5rem !important;\n}\n\n.pt-3 {\n padding-top: 1rem !important;\n}\n\n.pt-4 {\n padding-top: 1.5rem !important;\n}\n\n.pt-5 {\n padding-top: 3rem !important;\n}\n\n.pe-0 {\n padding-left: 0 !important;\n}\n\n.pe-1 {\n padding-left: 0.25rem !important;\n}\n\n.pe-2 {\n padding-left: 0.5rem !important;\n}\n\n.pe-3 {\n padding-left: 1rem !important;\n}\n\n.pe-4 {\n padding-left: 1.5rem !important;\n}\n\n.pe-5 {\n padding-left: 3rem !important;\n}\n\n.pb-0 {\n padding-bottom: 0 !important;\n}\n\n.pb-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pb-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pb-3 {\n padding-bottom: 1rem !important;\n}\n\n.pb-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pb-5 {\n padding-bottom: 3rem !important;\n}\n\n.ps-0 {\n padding-right: 0 !important;\n}\n\n.ps-1 {\n padding-right: 0.25rem !important;\n}\n\n.ps-2 {\n padding-right: 0.5rem !important;\n}\n\n.ps-3 {\n padding-right: 1rem !important;\n}\n\n.ps-4 {\n padding-right: 1.5rem !important;\n}\n\n.ps-5 {\n padding-right: 3rem !important;\n}\n\n@media (min-width: 576px) {\n .d-sm-inline {\n display: inline !important;\n }\n .d-sm-inline-block {\n display: inline-block !important;\n }\n .d-sm-block {\n display: block !important;\n }\n .d-sm-grid {\n display: grid !important;\n }\n .d-sm-inline-grid {\n display: inline-grid !important;\n }\n .d-sm-table {\n display: table !important;\n }\n .d-sm-table-row {\n display: table-row !important;\n }\n .d-sm-table-cell {\n display: table-cell !important;\n }\n .d-sm-flex {\n display: flex !important;\n }\n .d-sm-inline-flex {\n display: inline-flex !important;\n }\n .d-sm-none {\n display: none !important;\n }\n .flex-sm-fill {\n flex: 1 1 auto !important;\n }\n .flex-sm-row {\n flex-direction: row !important;\n }\n .flex-sm-column {\n flex-direction: column !important;\n }\n .flex-sm-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-sm-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-sm-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-sm-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-sm-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-sm-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-sm-wrap {\n flex-wrap: wrap !important;\n }\n .flex-sm-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-sm-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-sm-start {\n justify-content: flex-start !important;\n }\n .justify-content-sm-end {\n justify-content: flex-end !important;\n }\n .justify-content-sm-center {\n justify-content: center !important;\n }\n .justify-content-sm-between {\n justify-content: space-between !important;\n }\n .justify-content-sm-around {\n justify-content: space-around !important;\n }\n .justify-content-sm-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-sm-start {\n align-items: flex-start !important;\n }\n .align-items-sm-end {\n align-items: flex-end !important;\n }\n .align-items-sm-center {\n align-items: center !important;\n }\n .align-items-sm-baseline {\n align-items: baseline !important;\n }\n .align-items-sm-stretch {\n align-items: stretch !important;\n }\n .align-content-sm-start {\n align-content: flex-start !important;\n }\n .align-content-sm-end {\n align-content: flex-end !important;\n }\n .align-content-sm-center {\n align-content: center !important;\n }\n .align-content-sm-between {\n align-content: space-between !important;\n }\n .align-content-sm-around {\n align-content: space-around !important;\n }\n .align-content-sm-stretch {\n align-content: stretch !important;\n }\n .align-self-sm-auto {\n align-self: auto !important;\n }\n .align-self-sm-start {\n align-self: flex-start !important;\n }\n .align-self-sm-end {\n align-self: flex-end !important;\n }\n .align-self-sm-center {\n align-self: center !important;\n }\n .align-self-sm-baseline {\n align-self: baseline !important;\n }\n .align-self-sm-stretch {\n align-self: stretch !important;\n }\n .order-sm-first {\n order: -1 !important;\n }\n .order-sm-0 {\n order: 0 !important;\n }\n .order-sm-1 {\n order: 1 !important;\n }\n .order-sm-2 {\n order: 2 !important;\n }\n .order-sm-3 {\n order: 3 !important;\n }\n .order-sm-4 {\n order: 4 !important;\n }\n .order-sm-5 {\n order: 5 !important;\n }\n .order-sm-last {\n order: 6 !important;\n }\n .m-sm-0 {\n margin: 0 !important;\n }\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n .m-sm-3 {\n margin: 1rem !important;\n }\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n .m-sm-5 {\n margin: 3rem !important;\n }\n .m-sm-auto {\n margin: auto !important;\n }\n .mx-sm-0 {\n margin-left: 0 !important;\n margin-right: 0 !important;\n }\n .mx-sm-1 {\n margin-left: 0.25rem !important;\n margin-right: 0.25rem !important;\n }\n .mx-sm-2 {\n margin-left: 0.5rem !important;\n margin-right: 0.5rem !important;\n }\n .mx-sm-3 {\n margin-left: 1rem !important;\n margin-right: 1rem !important;\n }\n .mx-sm-4 {\n margin-left: 1.5rem !important;\n margin-right: 1.5rem !important;\n }\n .mx-sm-5 {\n margin-left: 3rem !important;\n margin-right: 3rem !important;\n }\n .mx-sm-auto {\n margin-left: auto !important;\n margin-right: auto !important;\n }\n .my-sm-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-sm-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-sm-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-sm-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-sm-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-sm-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-sm-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-sm-0 {\n margin-top: 0 !important;\n }\n .mt-sm-1 {\n margin-top: 0.25rem !important;\n }\n .mt-sm-2 {\n margin-top: 0.5rem !important;\n }\n .mt-sm-3 {\n margin-top: 1rem !important;\n }\n .mt-sm-4 {\n margin-top: 1.5rem !important;\n }\n .mt-sm-5 {\n margin-top: 3rem !important;\n }\n .mt-sm-auto {\n margin-top: auto !important;\n }\n .me-sm-0 {\n margin-left: 0 !important;\n }\n .me-sm-1 {\n margin-left: 0.25rem !important;\n }\n .me-sm-2 {\n margin-left: 0.5rem !important;\n }\n .me-sm-3 {\n margin-left: 1rem !important;\n }\n .me-sm-4 {\n margin-left: 1.5rem !important;\n }\n .me-sm-5 {\n margin-left: 3rem !important;\n }\n .me-sm-auto {\n margin-left: auto !important;\n }\n .mb-sm-0 {\n margin-bottom: 0 !important;\n }\n .mb-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-sm-3 {\n margin-bottom: 1rem !important;\n }\n .mb-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-sm-5 {\n margin-bottom: 3rem !important;\n }\n .mb-sm-auto {\n margin-bottom: auto !important;\n }\n .ms-sm-0 {\n margin-right: 0 !important;\n }\n .ms-sm-1 {\n margin-right: 0.25rem !important;\n }\n .ms-sm-2 {\n margin-right: 0.5rem !important;\n }\n .ms-sm-3 {\n margin-right: 1rem !important;\n }\n .ms-sm-4 {\n margin-right: 1.5rem !important;\n }\n .ms-sm-5 {\n margin-right: 3rem !important;\n }\n .ms-sm-auto {\n margin-right: auto !important;\n }\n .p-sm-0 {\n padding: 0 !important;\n }\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n .p-sm-3 {\n padding: 1rem !important;\n }\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n .p-sm-5 {\n padding: 3rem !important;\n }\n .px-sm-0 {\n padding-left: 0 !important;\n padding-right: 0 !important;\n }\n .px-sm-1 {\n padding-left: 0.25rem !important;\n padding-right: 0.25rem !important;\n }\n .px-sm-2 {\n padding-left: 0.5rem !important;\n padding-right: 0.5rem !important;\n }\n .px-sm-3 {\n padding-left: 1rem !important;\n padding-right: 1rem !important;\n }\n .px-sm-4 {\n padding-left: 1.5rem !important;\n padding-right: 1.5rem !important;\n }\n .px-sm-5 {\n padding-left: 3rem !important;\n padding-right: 3rem !important;\n }\n .py-sm-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-sm-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-sm-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-sm-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-sm-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-sm-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-sm-0 {\n padding-top: 0 !important;\n }\n .pt-sm-1 {\n padding-top: 0.25rem !important;\n }\n .pt-sm-2 {\n padding-top: 0.5rem !important;\n }\n .pt-sm-3 {\n padding-top: 1rem !important;\n }\n .pt-sm-4 {\n padding-top: 1.5rem !important;\n }\n .pt-sm-5 {\n padding-top: 3rem !important;\n }\n .pe-sm-0 {\n padding-left: 0 !important;\n }\n .pe-sm-1 {\n padding-left: 0.25rem !important;\n }\n .pe-sm-2 {\n padding-left: 0.5rem !important;\n }\n .pe-sm-3 {\n padding-left: 1rem !important;\n }\n .pe-sm-4 {\n padding-left: 1.5rem !important;\n }\n .pe-sm-5 {\n padding-left: 3rem !important;\n }\n .pb-sm-0 {\n padding-bottom: 0 !important;\n }\n .pb-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-sm-3 {\n padding-bottom: 1rem !important;\n }\n .pb-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-sm-5 {\n padding-bottom: 3rem !important;\n }\n .ps-sm-0 {\n padding-right: 0 !important;\n }\n .ps-sm-1 {\n padding-right: 0.25rem !important;\n }\n .ps-sm-2 {\n padding-right: 0.5rem !important;\n }\n .ps-sm-3 {\n padding-right: 1rem !important;\n }\n .ps-sm-4 {\n padding-right: 1.5rem !important;\n }\n .ps-sm-5 {\n padding-right: 3rem !important;\n }\n}\n@media (min-width: 768px) {\n .d-md-inline {\n display: inline !important;\n }\n .d-md-inline-block {\n display: inline-block !important;\n }\n .d-md-block {\n display: block !important;\n }\n .d-md-grid {\n display: grid !important;\n }\n .d-md-inline-grid {\n display: inline-grid !important;\n }\n .d-md-table {\n display: table !important;\n }\n .d-md-table-row {\n display: table-row !important;\n }\n .d-md-table-cell {\n display: table-cell !important;\n }\n .d-md-flex {\n display: flex !important;\n }\n .d-md-inline-flex {\n display: inline-flex !important;\n }\n .d-md-none {\n display: none !important;\n }\n .flex-md-fill {\n flex: 1 1 auto !important;\n }\n .flex-md-row {\n flex-direction: row !important;\n }\n .flex-md-column {\n flex-direction: column !important;\n }\n .flex-md-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-md-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-md-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-md-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-md-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-md-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-md-wrap {\n flex-wrap: wrap !important;\n }\n .flex-md-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-md-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-md-start {\n justify-content: flex-start !important;\n }\n .justify-content-md-end {\n justify-content: flex-end !important;\n }\n .justify-content-md-center {\n justify-content: center !important;\n }\n .justify-content-md-between {\n justify-content: space-between !important;\n }\n .justify-content-md-around {\n justify-content: space-around !important;\n }\n .justify-content-md-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-md-start {\n align-items: flex-start !important;\n }\n .align-items-md-end {\n align-items: flex-end !important;\n }\n .align-items-md-center {\n align-items: center !important;\n }\n .align-items-md-baseline {\n align-items: baseline !important;\n }\n .align-items-md-stretch {\n align-items: stretch !important;\n }\n .align-content-md-start {\n align-content: flex-start !important;\n }\n .align-content-md-end {\n align-content: flex-end !important;\n }\n .align-content-md-center {\n align-content: center !important;\n }\n .align-content-md-between {\n align-content: space-between !important;\n }\n .align-content-md-around {\n align-content: space-around !important;\n }\n .align-content-md-stretch {\n align-content: stretch !important;\n }\n .align-self-md-auto {\n align-self: auto !important;\n }\n .align-self-md-start {\n align-self: flex-start !important;\n }\n .align-self-md-end {\n align-self: flex-end !important;\n }\n .align-self-md-center {\n align-self: center !important;\n }\n .align-self-md-baseline {\n align-self: baseline !important;\n }\n .align-self-md-stretch {\n align-self: stretch !important;\n }\n .order-md-first {\n order: -1 !important;\n }\n .order-md-0 {\n order: 0 !important;\n }\n .order-md-1 {\n order: 1 !important;\n }\n .order-md-2 {\n order: 2 !important;\n }\n .order-md-3 {\n order: 3 !important;\n }\n .order-md-4 {\n order: 4 !important;\n }\n .order-md-5 {\n order: 5 !important;\n }\n .order-md-last {\n order: 6 !important;\n }\n .m-md-0 {\n margin: 0 !important;\n }\n .m-md-1 {\n margin: 0.25rem !important;\n }\n .m-md-2 {\n margin: 0.5rem !important;\n }\n .m-md-3 {\n margin: 1rem !important;\n }\n .m-md-4 {\n margin: 1.5rem !important;\n }\n .m-md-5 {\n margin: 3rem !important;\n }\n .m-md-auto {\n margin: auto !important;\n }\n .mx-md-0 {\n margin-left: 0 !important;\n margin-right: 0 !important;\n }\n .mx-md-1 {\n margin-left: 0.25rem !important;\n margin-right: 0.25rem !important;\n }\n .mx-md-2 {\n margin-left: 0.5rem !important;\n margin-right: 0.5rem !important;\n }\n .mx-md-3 {\n margin-left: 1rem !important;\n margin-right: 1rem !important;\n }\n .mx-md-4 {\n margin-left: 1.5rem !important;\n margin-right: 1.5rem !important;\n }\n .mx-md-5 {\n margin-left: 3rem !important;\n margin-right: 3rem !important;\n }\n .mx-md-auto {\n margin-left: auto !important;\n margin-right: auto !important;\n }\n .my-md-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-md-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-md-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-md-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-md-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-md-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-md-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-md-0 {\n margin-top: 0 !important;\n }\n .mt-md-1 {\n margin-top: 0.25rem !important;\n }\n .mt-md-2 {\n margin-top: 0.5rem !important;\n }\n .mt-md-3 {\n margin-top: 1rem !important;\n }\n .mt-md-4 {\n margin-top: 1.5rem !important;\n }\n .mt-md-5 {\n margin-top: 3rem !important;\n }\n .mt-md-auto {\n margin-top: auto !important;\n }\n .me-md-0 {\n margin-left: 0 !important;\n }\n .me-md-1 {\n margin-left: 0.25rem !important;\n }\n .me-md-2 {\n margin-left: 0.5rem !important;\n }\n .me-md-3 {\n margin-left: 1rem !important;\n }\n .me-md-4 {\n margin-left: 1.5rem !important;\n }\n .me-md-5 {\n margin-left: 3rem !important;\n }\n .me-md-auto {\n margin-left: auto !important;\n }\n .mb-md-0 {\n margin-bottom: 0 !important;\n }\n .mb-md-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-md-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-md-3 {\n margin-bottom: 1rem !important;\n }\n .mb-md-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-md-5 {\n margin-bottom: 3rem !important;\n }\n .mb-md-auto {\n margin-bottom: auto !important;\n }\n .ms-md-0 {\n margin-right: 0 !important;\n }\n .ms-md-1 {\n margin-right: 0.25rem !important;\n }\n .ms-md-2 {\n margin-right: 0.5rem !important;\n }\n .ms-md-3 {\n margin-right: 1rem !important;\n }\n .ms-md-4 {\n margin-right: 1.5rem !important;\n }\n .ms-md-5 {\n margin-right: 3rem !important;\n }\n .ms-md-auto {\n margin-right: auto !important;\n }\n .p-md-0 {\n padding: 0 !important;\n }\n .p-md-1 {\n padding: 0.25rem !important;\n }\n .p-md-2 {\n padding: 0.5rem !important;\n }\n .p-md-3 {\n padding: 1rem !important;\n }\n .p-md-4 {\n padding: 1.5rem !important;\n }\n .p-md-5 {\n padding: 3rem !important;\n }\n .px-md-0 {\n padding-left: 0 !important;\n padding-right: 0 !important;\n }\n .px-md-1 {\n padding-left: 0.25rem !important;\n padding-right: 0.25rem !important;\n }\n .px-md-2 {\n padding-left: 0.5rem !important;\n padding-right: 0.5rem !important;\n }\n .px-md-3 {\n padding-left: 1rem !important;\n padding-right: 1rem !important;\n }\n .px-md-4 {\n padding-left: 1.5rem !important;\n padding-right: 1.5rem !important;\n }\n .px-md-5 {\n padding-left: 3rem !important;\n padding-right: 3rem !important;\n }\n .py-md-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-md-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-md-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-md-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-md-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-md-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-md-0 {\n padding-top: 0 !important;\n }\n .pt-md-1 {\n padding-top: 0.25rem !important;\n }\n .pt-md-2 {\n padding-top: 0.5rem !important;\n }\n .pt-md-3 {\n padding-top: 1rem !important;\n }\n .pt-md-4 {\n padding-top: 1.5rem !important;\n }\n .pt-md-5 {\n padding-top: 3rem !important;\n }\n .pe-md-0 {\n padding-left: 0 !important;\n }\n .pe-md-1 {\n padding-left: 0.25rem !important;\n }\n .pe-md-2 {\n padding-left: 0.5rem !important;\n }\n .pe-md-3 {\n padding-left: 1rem !important;\n }\n .pe-md-4 {\n padding-left: 1.5rem !important;\n }\n .pe-md-5 {\n padding-left: 3rem !important;\n }\n .pb-md-0 {\n padding-bottom: 0 !important;\n }\n .pb-md-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-md-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-md-3 {\n padding-bottom: 1rem !important;\n }\n .pb-md-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-md-5 {\n padding-bottom: 3rem !important;\n }\n .ps-md-0 {\n padding-right: 0 !important;\n }\n .ps-md-1 {\n padding-right: 0.25rem !important;\n }\n .ps-md-2 {\n padding-right: 0.5rem !important;\n }\n .ps-md-3 {\n padding-right: 1rem !important;\n }\n .ps-md-4 {\n padding-right: 1.5rem !important;\n }\n .ps-md-5 {\n padding-right: 3rem !important;\n }\n}\n@media (min-width: 992px) {\n .d-lg-inline {\n display: inline !important;\n }\n .d-lg-inline-block {\n display: inline-block !important;\n }\n .d-lg-block {\n display: block !important;\n }\n .d-lg-grid {\n display: grid !important;\n }\n .d-lg-inline-grid {\n display: inline-grid !important;\n }\n .d-lg-table {\n display: table !important;\n }\n .d-lg-table-row {\n display: table-row !important;\n }\n .d-lg-table-cell {\n display: table-cell !important;\n }\n .d-lg-flex {\n display: flex !important;\n }\n .d-lg-inline-flex {\n display: inline-flex !important;\n }\n .d-lg-none {\n display: none !important;\n }\n .flex-lg-fill {\n flex: 1 1 auto !important;\n }\n .flex-lg-row {\n flex-direction: row !important;\n }\n .flex-lg-column {\n flex-direction: column !important;\n }\n .flex-lg-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-lg-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-lg-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-lg-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-lg-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-lg-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-lg-wrap {\n flex-wrap: wrap !important;\n }\n .flex-lg-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-lg-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-lg-start {\n justify-content: flex-start !important;\n }\n .justify-content-lg-end {\n justify-content: flex-end !important;\n }\n .justify-content-lg-center {\n justify-content: center !important;\n }\n .justify-content-lg-between {\n justify-content: space-between !important;\n }\n .justify-content-lg-around {\n justify-content: space-around !important;\n }\n .justify-content-lg-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-lg-start {\n align-items: flex-start !important;\n }\n .align-items-lg-end {\n align-items: flex-end !important;\n }\n .align-items-lg-center {\n align-items: center !important;\n }\n .align-items-lg-baseline {\n align-items: baseline !important;\n }\n .align-items-lg-stretch {\n align-items: stretch !important;\n }\n .align-content-lg-start {\n align-content: flex-start !important;\n }\n .align-content-lg-end {\n align-content: flex-end !important;\n }\n .align-content-lg-center {\n align-content: center !important;\n }\n .align-content-lg-between {\n align-content: space-between !important;\n }\n .align-content-lg-around {\n align-content: space-around !important;\n }\n .align-content-lg-stretch {\n align-content: stretch !important;\n }\n .align-self-lg-auto {\n align-self: auto !important;\n }\n .align-self-lg-start {\n align-self: flex-start !important;\n }\n .align-self-lg-end {\n align-self: flex-end !important;\n }\n .align-self-lg-center {\n align-self: center !important;\n }\n .align-self-lg-baseline {\n align-self: baseline !important;\n }\n .align-self-lg-stretch {\n align-self: stretch !important;\n }\n .order-lg-first {\n order: -1 !important;\n }\n .order-lg-0 {\n order: 0 !important;\n }\n .order-lg-1 {\n order: 1 !important;\n }\n .order-lg-2 {\n order: 2 !important;\n }\n .order-lg-3 {\n order: 3 !important;\n }\n .order-lg-4 {\n order: 4 !important;\n }\n .order-lg-5 {\n order: 5 !important;\n }\n .order-lg-last {\n order: 6 !important;\n }\n .m-lg-0 {\n margin: 0 !important;\n }\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n .m-lg-3 {\n margin: 1rem !important;\n }\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n .m-lg-5 {\n margin: 3rem !important;\n }\n .m-lg-auto {\n margin: auto !important;\n }\n .mx-lg-0 {\n margin-left: 0 !important;\n margin-right: 0 !important;\n }\n .mx-lg-1 {\n margin-left: 0.25rem !important;\n margin-right: 0.25rem !important;\n }\n .mx-lg-2 {\n margin-left: 0.5rem !important;\n margin-right: 0.5rem !important;\n }\n .mx-lg-3 {\n margin-left: 1rem !important;\n margin-right: 1rem !important;\n }\n .mx-lg-4 {\n margin-left: 1.5rem !important;\n margin-right: 1.5rem !important;\n }\n .mx-lg-5 {\n margin-left: 3rem !important;\n margin-right: 3rem !important;\n }\n .mx-lg-auto {\n margin-left: auto !important;\n margin-right: auto !important;\n }\n .my-lg-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-lg-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-lg-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-lg-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-lg-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-lg-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-lg-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-lg-0 {\n margin-top: 0 !important;\n }\n .mt-lg-1 {\n margin-top: 0.25rem !important;\n }\n .mt-lg-2 {\n margin-top: 0.5rem !important;\n }\n .mt-lg-3 {\n margin-top: 1rem !important;\n }\n .mt-lg-4 {\n margin-top: 1.5rem !important;\n }\n .mt-lg-5 {\n margin-top: 3rem !important;\n }\n .mt-lg-auto {\n margin-top: auto !important;\n }\n .me-lg-0 {\n margin-left: 0 !important;\n }\n .me-lg-1 {\n margin-left: 0.25rem !important;\n }\n .me-lg-2 {\n margin-left: 0.5rem !important;\n }\n .me-lg-3 {\n margin-left: 1rem !important;\n }\n .me-lg-4 {\n margin-left: 1.5rem !important;\n }\n .me-lg-5 {\n margin-left: 3rem !important;\n }\n .me-lg-auto {\n margin-left: auto !important;\n }\n .mb-lg-0 {\n margin-bottom: 0 !important;\n }\n .mb-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-lg-3 {\n margin-bottom: 1rem !important;\n }\n .mb-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-lg-5 {\n margin-bottom: 3rem !important;\n }\n .mb-lg-auto {\n margin-bottom: auto !important;\n }\n .ms-lg-0 {\n margin-right: 0 !important;\n }\n .ms-lg-1 {\n margin-right: 0.25rem !important;\n }\n .ms-lg-2 {\n margin-right: 0.5rem !important;\n }\n .ms-lg-3 {\n margin-right: 1rem !important;\n }\n .ms-lg-4 {\n margin-right: 1.5rem !important;\n }\n .ms-lg-5 {\n margin-right: 3rem !important;\n }\n .ms-lg-auto {\n margin-right: auto !important;\n }\n .p-lg-0 {\n padding: 0 !important;\n }\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n .p-lg-3 {\n padding: 1rem !important;\n }\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n .p-lg-5 {\n padding: 3rem !important;\n }\n .px-lg-0 {\n padding-left: 0 !important;\n padding-right: 0 !important;\n }\n .px-lg-1 {\n padding-left: 0.25rem !important;\n padding-right: 0.25rem !important;\n }\n .px-lg-2 {\n padding-left: 0.5rem !important;\n padding-right: 0.5rem !important;\n }\n .px-lg-3 {\n padding-left: 1rem !important;\n padding-right: 1rem !important;\n }\n .px-lg-4 {\n padding-left: 1.5rem !important;\n padding-right: 1.5rem !important;\n }\n .px-lg-5 {\n padding-left: 3rem !important;\n padding-right: 3rem !important;\n }\n .py-lg-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-lg-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-lg-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-lg-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-lg-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-lg-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-lg-0 {\n padding-top: 0 !important;\n }\n .pt-lg-1 {\n padding-top: 0.25rem !important;\n }\n .pt-lg-2 {\n padding-top: 0.5rem !important;\n }\n .pt-lg-3 {\n padding-top: 1rem !important;\n }\n .pt-lg-4 {\n padding-top: 1.5rem !important;\n }\n .pt-lg-5 {\n padding-top: 3rem !important;\n }\n .pe-lg-0 {\n padding-left: 0 !important;\n }\n .pe-lg-1 {\n padding-left: 0.25rem !important;\n }\n .pe-lg-2 {\n padding-left: 0.5rem !important;\n }\n .pe-lg-3 {\n padding-left: 1rem !important;\n }\n .pe-lg-4 {\n padding-left: 1.5rem !important;\n }\n .pe-lg-5 {\n padding-left: 3rem !important;\n }\n .pb-lg-0 {\n padding-bottom: 0 !important;\n }\n .pb-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-lg-3 {\n padding-bottom: 1rem !important;\n }\n .pb-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-lg-5 {\n padding-bottom: 3rem !important;\n }\n .ps-lg-0 {\n padding-right: 0 !important;\n }\n .ps-lg-1 {\n padding-right: 0.25rem !important;\n }\n .ps-lg-2 {\n padding-right: 0.5rem !important;\n }\n .ps-lg-3 {\n padding-right: 1rem !important;\n }\n .ps-lg-4 {\n padding-right: 1.5rem !important;\n }\n .ps-lg-5 {\n padding-right: 3rem !important;\n }\n}\n@media (min-width: 1200px) {\n .d-xl-inline {\n display: inline !important;\n }\n .d-xl-inline-block {\n display: inline-block !important;\n }\n .d-xl-block {\n display: block !important;\n }\n .d-xl-grid {\n display: grid !important;\n }\n .d-xl-inline-grid {\n display: inline-grid !important;\n }\n .d-xl-table {\n display: table !important;\n }\n .d-xl-table-row {\n display: table-row !important;\n }\n .d-xl-table-cell {\n display: table-cell !important;\n }\n .d-xl-flex {\n display: flex !important;\n }\n .d-xl-inline-flex {\n display: inline-flex !important;\n }\n .d-xl-none {\n display: none !important;\n }\n .flex-xl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xl-row {\n flex-direction: row !important;\n }\n .flex-xl-column {\n flex-direction: column !important;\n }\n .flex-xl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xl-center {\n justify-content: center !important;\n }\n .justify-content-xl-between {\n justify-content: space-between !important;\n }\n .justify-content-xl-around {\n justify-content: space-around !important;\n }\n .justify-content-xl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xl-start {\n align-items: flex-start !important;\n }\n .align-items-xl-end {\n align-items: flex-end !important;\n }\n .align-items-xl-center {\n align-items: center !important;\n }\n .align-items-xl-baseline {\n align-items: baseline !important;\n }\n .align-items-xl-stretch {\n align-items: stretch !important;\n }\n .align-content-xl-start {\n align-content: flex-start !important;\n }\n .align-content-xl-end {\n align-content: flex-end !important;\n }\n .align-content-xl-center {\n align-content: center !important;\n }\n .align-content-xl-between {\n align-content: space-between !important;\n }\n .align-content-xl-around {\n align-content: space-around !important;\n }\n .align-content-xl-stretch {\n align-content: stretch !important;\n }\n .align-self-xl-auto {\n align-self: auto !important;\n }\n .align-self-xl-start {\n align-self: flex-start !important;\n }\n .align-self-xl-end {\n align-self: flex-end !important;\n }\n .align-self-xl-center {\n align-self: center !important;\n }\n .align-self-xl-baseline {\n align-self: baseline !important;\n }\n .align-self-xl-stretch {\n align-self: stretch !important;\n }\n .order-xl-first {\n order: -1 !important;\n }\n .order-xl-0 {\n order: 0 !important;\n }\n .order-xl-1 {\n order: 1 !important;\n }\n .order-xl-2 {\n order: 2 !important;\n }\n .order-xl-3 {\n order: 3 !important;\n }\n .order-xl-4 {\n order: 4 !important;\n }\n .order-xl-5 {\n order: 5 !important;\n }\n .order-xl-last {\n order: 6 !important;\n }\n .m-xl-0 {\n margin: 0 !important;\n }\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n .m-xl-3 {\n margin: 1rem !important;\n }\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n .m-xl-5 {\n margin: 3rem !important;\n }\n .m-xl-auto {\n margin: auto !important;\n }\n .mx-xl-0 {\n margin-left: 0 !important;\n margin-right: 0 !important;\n }\n .mx-xl-1 {\n margin-left: 0.25rem !important;\n margin-right: 0.25rem !important;\n }\n .mx-xl-2 {\n margin-left: 0.5rem !important;\n margin-right: 0.5rem !important;\n }\n .mx-xl-3 {\n margin-left: 1rem !important;\n margin-right: 1rem !important;\n }\n .mx-xl-4 {\n margin-left: 1.5rem !important;\n margin-right: 1.5rem !important;\n }\n .mx-xl-5 {\n margin-left: 3rem !important;\n margin-right: 3rem !important;\n }\n .mx-xl-auto {\n margin-left: auto !important;\n margin-right: auto !important;\n }\n .my-xl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xl-0 {\n margin-top: 0 !important;\n }\n .mt-xl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xl-3 {\n margin-top: 1rem !important;\n }\n .mt-xl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xl-5 {\n margin-top: 3rem !important;\n }\n .mt-xl-auto {\n margin-top: auto !important;\n }\n .me-xl-0 {\n margin-left: 0 !important;\n }\n .me-xl-1 {\n margin-left: 0.25rem !important;\n }\n .me-xl-2 {\n margin-left: 0.5rem !important;\n }\n .me-xl-3 {\n margin-left: 1rem !important;\n }\n .me-xl-4 {\n margin-left: 1.5rem !important;\n }\n .me-xl-5 {\n margin-left: 3rem !important;\n }\n .me-xl-auto {\n margin-left: auto !important;\n }\n .mb-xl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xl-auto {\n margin-bottom: auto !important;\n }\n .ms-xl-0 {\n margin-right: 0 !important;\n }\n .ms-xl-1 {\n margin-right: 0.25rem !important;\n }\n .ms-xl-2 {\n margin-right: 0.5rem !important;\n }\n .ms-xl-3 {\n margin-right: 1rem !important;\n }\n .ms-xl-4 {\n margin-right: 1.5rem !important;\n }\n .ms-xl-5 {\n margin-right: 3rem !important;\n }\n .ms-xl-auto {\n margin-right: auto !important;\n }\n .p-xl-0 {\n padding: 0 !important;\n }\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n .p-xl-3 {\n padding: 1rem !important;\n }\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n .p-xl-5 {\n padding: 3rem !important;\n }\n .px-xl-0 {\n padding-left: 0 !important;\n padding-right: 0 !important;\n }\n .px-xl-1 {\n padding-left: 0.25rem !important;\n padding-right: 0.25rem !important;\n }\n .px-xl-2 {\n padding-left: 0.5rem !important;\n padding-right: 0.5rem !important;\n }\n .px-xl-3 {\n padding-left: 1rem !important;\n padding-right: 1rem !important;\n }\n .px-xl-4 {\n padding-left: 1.5rem !important;\n padding-right: 1.5rem !important;\n }\n .px-xl-5 {\n padding-left: 3rem !important;\n padding-right: 3rem !important;\n }\n .py-xl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xl-0 {\n padding-top: 0 !important;\n }\n .pt-xl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xl-3 {\n padding-top: 1rem !important;\n }\n .pt-xl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xl-5 {\n padding-top: 3rem !important;\n }\n .pe-xl-0 {\n padding-left: 0 !important;\n }\n .pe-xl-1 {\n padding-left: 0.25rem !important;\n }\n .pe-xl-2 {\n padding-left: 0.5rem !important;\n }\n .pe-xl-3 {\n padding-left: 1rem !important;\n }\n .pe-xl-4 {\n padding-left: 1.5rem !important;\n }\n .pe-xl-5 {\n padding-left: 3rem !important;\n }\n .pb-xl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xl-0 {\n padding-right: 0 !important;\n }\n .ps-xl-1 {\n padding-right: 0.25rem !important;\n }\n .ps-xl-2 {\n padding-right: 0.5rem !important;\n }\n .ps-xl-3 {\n padding-right: 1rem !important;\n }\n .ps-xl-4 {\n padding-right: 1.5rem !important;\n }\n .ps-xl-5 {\n padding-right: 3rem !important;\n }\n}\n@media (min-width: 1400px) {\n .d-xxl-inline {\n display: inline !important;\n }\n .d-xxl-inline-block {\n display: inline-block !important;\n }\n .d-xxl-block {\n display: block !important;\n }\n .d-xxl-grid {\n display: grid !important;\n }\n .d-xxl-inline-grid {\n display: inline-grid !important;\n }\n .d-xxl-table {\n display: table !important;\n }\n .d-xxl-table-row {\n display: table-row !important;\n }\n .d-xxl-table-cell {\n display: table-cell !important;\n }\n .d-xxl-flex {\n display: flex !important;\n }\n .d-xxl-inline-flex {\n display: inline-flex !important;\n }\n .d-xxl-none {\n display: none !important;\n }\n .flex-xxl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xxl-row {\n flex-direction: row !important;\n }\n .flex-xxl-column {\n flex-direction: column !important;\n }\n .flex-xxl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xxl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xxl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xxl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xxl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xxl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xxl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xxl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xxl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xxl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xxl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xxl-center {\n justify-content: center !important;\n }\n .justify-content-xxl-between {\n justify-content: space-between !important;\n }\n .justify-content-xxl-around {\n justify-content: space-around !important;\n }\n .justify-content-xxl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xxl-start {\n align-items: flex-start !important;\n }\n .align-items-xxl-end {\n align-items: flex-end !important;\n }\n .align-items-xxl-center {\n align-items: center !important;\n }\n .align-items-xxl-baseline {\n align-items: baseline !important;\n }\n .align-items-xxl-stretch {\n align-items: stretch !important;\n }\n .align-content-xxl-start {\n align-content: flex-start !important;\n }\n .align-content-xxl-end {\n align-content: flex-end !important;\n }\n .align-content-xxl-center {\n align-content: center !important;\n }\n .align-content-xxl-between {\n align-content: space-between !important;\n }\n .align-content-xxl-around {\n align-content: space-around !important;\n }\n .align-content-xxl-stretch {\n align-content: stretch !important;\n }\n .align-self-xxl-auto {\n align-self: auto !important;\n }\n .align-self-xxl-start {\n align-self: flex-start !important;\n }\n .align-self-xxl-end {\n align-self: flex-end !important;\n }\n .align-self-xxl-center {\n align-self: center !important;\n }\n .align-self-xxl-baseline {\n align-self: baseline !important;\n }\n .align-self-xxl-stretch {\n align-self: stretch !important;\n }\n .order-xxl-first {\n order: -1 !important;\n }\n .order-xxl-0 {\n order: 0 !important;\n }\n .order-xxl-1 {\n order: 1 !important;\n }\n .order-xxl-2 {\n order: 2 !important;\n }\n .order-xxl-3 {\n order: 3 !important;\n }\n .order-xxl-4 {\n order: 4 !important;\n }\n .order-xxl-5 {\n order: 5 !important;\n }\n .order-xxl-last {\n order: 6 !important;\n }\n .m-xxl-0 {\n margin: 0 !important;\n }\n .m-xxl-1 {\n margin: 0.25rem !important;\n }\n .m-xxl-2 {\n margin: 0.5rem !important;\n }\n .m-xxl-3 {\n margin: 1rem !important;\n }\n .m-xxl-4 {\n margin: 1.5rem !important;\n }\n .m-xxl-5 {\n margin: 3rem !important;\n }\n .m-xxl-auto {\n margin: auto !important;\n }\n .mx-xxl-0 {\n margin-left: 0 !important;\n margin-right: 0 !important;\n }\n .mx-xxl-1 {\n margin-left: 0.25rem !important;\n margin-right: 0.25rem !important;\n }\n .mx-xxl-2 {\n margin-left: 0.5rem !important;\n margin-right: 0.5rem !important;\n }\n .mx-xxl-3 {\n margin-left: 1rem !important;\n margin-right: 1rem !important;\n }\n .mx-xxl-4 {\n margin-left: 1.5rem !important;\n margin-right: 1.5rem !important;\n }\n .mx-xxl-5 {\n margin-left: 3rem !important;\n margin-right: 3rem !important;\n }\n .mx-xxl-auto {\n margin-left: auto !important;\n margin-right: auto !important;\n }\n .my-xxl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xxl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xxl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xxl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xxl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xxl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xxl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xxl-0 {\n margin-top: 0 !important;\n }\n .mt-xxl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xxl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xxl-3 {\n margin-top: 1rem !important;\n }\n .mt-xxl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xxl-5 {\n margin-top: 3rem !important;\n }\n .mt-xxl-auto {\n margin-top: auto !important;\n }\n .me-xxl-0 {\n margin-left: 0 !important;\n }\n .me-xxl-1 {\n margin-left: 0.25rem !important;\n }\n .me-xxl-2 {\n margin-left: 0.5rem !important;\n }\n .me-xxl-3 {\n margin-left: 1rem !important;\n }\n .me-xxl-4 {\n margin-left: 1.5rem !important;\n }\n .me-xxl-5 {\n margin-left: 3rem !important;\n }\n .me-xxl-auto {\n margin-left: auto !important;\n }\n .mb-xxl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xxl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xxl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xxl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xxl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xxl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xxl-auto {\n margin-bottom: auto !important;\n }\n .ms-xxl-0 {\n margin-right: 0 !important;\n }\n .ms-xxl-1 {\n margin-right: 0.25rem !important;\n }\n .ms-xxl-2 {\n margin-right: 0.5rem !important;\n }\n .ms-xxl-3 {\n margin-right: 1rem !important;\n }\n .ms-xxl-4 {\n margin-right: 1.5rem !important;\n }\n .ms-xxl-5 {\n margin-right: 3rem !important;\n }\n .ms-xxl-auto {\n margin-right: auto !important;\n }\n .p-xxl-0 {\n padding: 0 !important;\n }\n .p-xxl-1 {\n padding: 0.25rem !important;\n }\n .p-xxl-2 {\n padding: 0.5rem !important;\n }\n .p-xxl-3 {\n padding: 1rem !important;\n }\n .p-xxl-4 {\n padding: 1.5rem !important;\n }\n .p-xxl-5 {\n padding: 3rem !important;\n }\n .px-xxl-0 {\n padding-left: 0 !important;\n padding-right: 0 !important;\n }\n .px-xxl-1 {\n padding-left: 0.25rem !important;\n padding-right: 0.25rem !important;\n }\n .px-xxl-2 {\n padding-left: 0.5rem !important;\n padding-right: 0.5rem !important;\n }\n .px-xxl-3 {\n padding-left: 1rem !important;\n padding-right: 1rem !important;\n }\n .px-xxl-4 {\n padding-left: 1.5rem !important;\n padding-right: 1.5rem !important;\n }\n .px-xxl-5 {\n padding-left: 3rem !important;\n padding-right: 3rem !important;\n }\n .py-xxl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xxl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xxl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xxl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xxl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xxl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xxl-0 {\n padding-top: 0 !important;\n }\n .pt-xxl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xxl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xxl-3 {\n padding-top: 1rem !important;\n }\n .pt-xxl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xxl-5 {\n padding-top: 3rem !important;\n }\n .pe-xxl-0 {\n padding-left: 0 !important;\n }\n .pe-xxl-1 {\n padding-left: 0.25rem !important;\n }\n .pe-xxl-2 {\n padding-left: 0.5rem !important;\n }\n .pe-xxl-3 {\n padding-left: 1rem !important;\n }\n .pe-xxl-4 {\n padding-left: 1.5rem !important;\n }\n .pe-xxl-5 {\n padding-left: 3rem !important;\n }\n .pb-xxl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xxl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xxl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xxl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xxl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xxl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xxl-0 {\n padding-right: 0 !important;\n }\n .ps-xxl-1 {\n padding-right: 0.25rem !important;\n }\n .ps-xxl-2 {\n padding-right: 0.5rem !important;\n }\n .ps-xxl-3 {\n padding-right: 1rem !important;\n }\n .ps-xxl-4 {\n padding-right: 1.5rem !important;\n }\n .ps-xxl-5 {\n padding-right: 3rem !important;\n }\n}\n@media print {\n .d-print-inline {\n display: inline !important;\n }\n .d-print-inline-block {\n display: inline-block !important;\n }\n .d-print-block {\n display: block !important;\n }\n .d-print-grid {\n display: grid !important;\n }\n .d-print-inline-grid {\n display: inline-grid !important;\n }\n .d-print-table {\n display: table !important;\n }\n .d-print-table-row {\n display: table-row !important;\n }\n .d-print-table-cell {\n display: table-cell !important;\n }\n .d-print-flex {\n display: flex !important;\n }\n .d-print-inline-flex {\n display: inline-flex !important;\n }\n .d-print-none {\n display: none !important;\n }\n}\n/*# sourceMappingURL=bootstrap-grid.rtl.css.map */","// Container mixins\n\n@mixin make-container($gutter: $container-padding-x) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-right: auto;\n margin-left: auto;\n}\n","// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)\n//\n// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n// >> breakpoint-next(sm)\n// md\n// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// md\n// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl))\n// md\n@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {\n $n: index($breakpoint-names, $name);\n @if not $n {\n @error \"breakpoint `#{$name}` not found in `#{$breakpoints}`\";\n }\n @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 576px\n@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {\n $min: map-get($breakpoints, $name);\n @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width.\n// The maximum value is reduced by 0.02px to work around the limitations of\n// `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 767.98px\n@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {\n $max: map-get($breakpoints, $name);\n @return if($max and $max > 0, $max - .02, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.\n// Useful for making responsive utilities.\n//\n// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"\" (Returns a blank string)\n// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"-sm\"\n@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {\n @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n @if $min {\n @media (min-width: $min) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {\n $max: breakpoint-max($name, $breakpoints);\n @if $max {\n @media (max-width: $max) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($lower, $breakpoints);\n $max: breakpoint-max($upper, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($lower, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($upper, $breakpoints) {\n @content;\n }\n }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n $next: breakpoint-next($name, $breakpoints);\n $max: breakpoint-max($next, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($name, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($next, $breakpoints) {\n @content;\n }\n }\n}\n","// Row\n//\n// Rows contain your columns.\n\n:root {\n @each $name, $value in $grid-breakpoints {\n --#{$prefix}breakpoint-#{$name}: #{$value};\n }\n}\n\n@if $enable-grid-classes {\n .row {\n @include make-row();\n\n > * {\n @include make-col-ready();\n }\n }\n}\n\n@if $enable-cssgrid {\n .grid {\n display: grid;\n grid-template-rows: repeat(var(--#{$prefix}rows, 1), 1fr);\n grid-template-columns: repeat(var(--#{$prefix}columns, #{$grid-columns}), 1fr);\n gap: var(--#{$prefix}gap, #{$grid-gutter-width});\n\n @include make-cssgrid();\n }\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n@if $enable-grid-classes {\n @include make-grid-columns();\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n@mixin make-row($gutter: $grid-gutter-width) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n // TODO: Revisit calc order after https://github.com/react-bootstrap/react-bootstrap/issues/6039 is fixed\n margin-top: calc(-1 * var(--#{$prefix}gutter-y)); // stylelint-disable-line function-disallowed-list\n margin-right: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n margin-left: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n}\n\n@mixin make-col-ready() {\n // Add box sizing if only the grid is loaded\n box-sizing: if(variable-exists(include-column-box-sizing) and $include-column-box-sizing, border-box, null);\n // Prevent columns from becoming too narrow when at smaller grid tiers by\n // always setting `width: 100%;`. This works because we set the width\n // later on to override this initial width.\n flex-shrink: 0;\n width: 100%;\n max-width: 100%; // Prevent `.col-auto`, `.col` (& responsive variants) from breaking out the grid\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-top: var(--#{$prefix}gutter-y);\n}\n\n@mixin make-col($size: false, $columns: $grid-columns) {\n @if $size {\n flex: 0 0 auto;\n width: percentage(divide($size, $columns));\n\n } @else {\n flex: 1 1 0;\n max-width: 100%;\n }\n}\n\n@mixin make-col-auto() {\n flex: 0 0 auto;\n width: auto;\n}\n\n@mixin make-col-offset($size, $columns: $grid-columns) {\n $num: divide($size, $columns);\n margin-left: if($num == 0, 0, percentage($num));\n}\n\n// Row columns\n//\n// Specify on a parent element(e.g., .row) to force immediate children into NN\n// number of columns. Supports wrapping to new lines, but does not do a Masonry\n// style grid.\n@mixin row-cols($count) {\n > * {\n flex: 0 0 auto;\n width: percentage(divide(1, $count));\n }\n}\n\n// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `$grid-columns`.\n\n@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n // Provide basic `.col-{bp}` classes for equal-width flexbox columns\n .col#{$infix} {\n flex: 1 0 0%; // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4\n }\n\n .row-cols#{$infix}-auto > * {\n @include make-col-auto();\n }\n\n @if $grid-row-columns > 0 {\n @for $i from 1 through $grid-row-columns {\n .row-cols#{$infix}-#{$i} {\n @include row-cols($i);\n }\n }\n }\n\n .col#{$infix}-auto {\n @include make-col-auto();\n }\n\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @include make-col($i, $columns);\n }\n }\n\n // `$columns - 1` because offsetting by the width of an entire row isn't possible\n @for $i from 0 through ($columns - 1) {\n @if not ($infix == \"\" and $i == 0) { // Avoid emitting useless .offset-0\n .offset#{$infix}-#{$i} {\n @include make-col-offset($i, $columns);\n }\n }\n }\n }\n\n // Gutters\n //\n // Make use of `.g-*`, `.gx-*` or `.gy-*` utilities to change spacing between the columns.\n @each $key, $value in $gutters {\n .g#{$infix}-#{$key},\n .gx#{$infix}-#{$key} {\n --#{$prefix}gutter-x: #{$value};\n }\n\n .g#{$infix}-#{$key},\n .gy#{$infix}-#{$key} {\n --#{$prefix}gutter-y: #{$value};\n }\n }\n }\n }\n}\n\n@mixin make-cssgrid($columns: $grid-columns, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .g-col#{$infix}-#{$i} {\n grid-column: auto / span $i;\n }\n }\n\n // Start with `1` because `0` is an invalid value.\n // Ends with `$columns - 1` because offsetting by the width of an entire row isn't possible.\n @for $i from 1 through ($columns - 1) {\n .g-start#{$infix}-#{$i} {\n grid-column-start: $i;\n }\n }\n }\n }\n }\n}\n","// Utility generator\n// Used to generate utilities & print utilities\n@mixin generate-utility($utility, $infix: \"\", $is-rfs-media-query: false) {\n $values: map-get($utility, values);\n\n // If the values are a list or string, convert it into a map\n @if type-of($values) == \"string\" or type-of(nth($values, 1)) != \"list\" {\n $values: zip($values, $values);\n }\n\n @each $key, $value in $values {\n $properties: map-get($utility, property);\n\n // Multiple properties are possible, for example with vertical or horizontal margins or paddings\n @if type-of($properties) == \"string\" {\n $properties: append((), $properties);\n }\n\n // Use custom class if present\n $property-class: if(map-has-key($utility, class), map-get($utility, class), nth($properties, 1));\n $property-class: if($property-class == null, \"\", $property-class);\n\n // Use custom CSS variable name if present, otherwise default to `class`\n $css-variable-name: if(map-has-key($utility, css-variable-name), map-get($utility, css-variable-name), map-get($utility, class));\n\n // State params to generate pseudo-classes\n $state: if(map-has-key($utility, state), map-get($utility, state), ());\n\n $infix: if($property-class == \"\" and str-slice($infix, 1, 1) == \"-\", str-slice($infix, 2), $infix);\n\n // Don't prefix if value key is null (e.g. with shadow class)\n $property-class-modifier: if($key, if($property-class == \"\" and $infix == \"\", \"\", \"-\") + $key, \"\");\n\n @if map-get($utility, rfs) {\n // Inside the media query\n @if $is-rfs-media-query {\n $val: rfs-value($value);\n\n // Do not render anything if fluid and non fluid values are the same\n $value: if($val == rfs-fluid-value($value), null, $val);\n }\n @else {\n $value: rfs-fluid-value($value);\n }\n }\n\n $is-css-var: map-get($utility, css-var);\n $is-local-vars: map-get($utility, local-vars);\n $is-rtl: map-get($utility, rtl);\n\n @if $value != null {\n @if $is-rtl == false {\n /* rtl:begin:remove */\n }\n\n @if $is-css-var {\n .#{$property-class + $infix + $property-class-modifier} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n }\n } @else {\n .#{$property-class + $infix + $property-class-modifier} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n }\n }\n\n @if $is-rtl == false {\n /* rtl:end:remove */\n }\n }\n }\n}\n","// Loop over each breakpoint\n@each $breakpoint in map-keys($grid-breakpoints) {\n\n // Generate media query if needed\n @include media-breakpoint-up($breakpoint) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix);\n }\n }\n }\n}\n\n// RFS rescaling\n@media (min-width: $rfs-mq-value) {\n @each $breakpoint in map-keys($grid-breakpoints) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n @if (map-get($grid-breakpoints, $breakpoint) < $rfs-breakpoint) {\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and map-get($utility, rfs) and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix, true);\n }\n }\n }\n }\n}\n\n\n// Print utilities\n@media print {\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Then check if the utility needs print styles\n @if type-of($utility) == \"map\" and map-get($utility, print) == true {\n @include generate-utility($utility, \"-print\");\n }\n }\n}\n"]} \ No newline at end of file diff --git a/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css new file mode 100644 index 0000000..88a550b --- /dev/null +++ b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css @@ -0,0 +1,597 @@ +/*! + * Bootstrap Reboot v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +:root, +[data-bs-theme=light] { + --bs-blue: #0d6efd; + --bs-indigo: #6610f2; + --bs-purple: #6f42c1; + --bs-pink: #d63384; + --bs-red: #dc3545; + --bs-orange: #fd7e14; + --bs-yellow: #ffc107; + --bs-green: #198754; + --bs-teal: #20c997; + --bs-cyan: #0dcaf0; + --bs-black: #000; + --bs-white: #fff; + --bs-gray: #6c757d; + --bs-gray-dark: #343a40; + --bs-gray-100: #f8f9fa; + --bs-gray-200: #e9ecef; + --bs-gray-300: #dee2e6; + --bs-gray-400: #ced4da; + --bs-gray-500: #adb5bd; + --bs-gray-600: #6c757d; + --bs-gray-700: #495057; + --bs-gray-800: #343a40; + --bs-gray-900: #212529; + --bs-primary: #0d6efd; + --bs-secondary: #6c757d; + --bs-success: #198754; + --bs-info: #0dcaf0; + --bs-warning: #ffc107; + --bs-danger: #dc3545; + --bs-light: #f8f9fa; + --bs-dark: #212529; + --bs-primary-rgb: 13, 110, 253; + --bs-secondary-rgb: 108, 117, 125; + --bs-success-rgb: 25, 135, 84; + --bs-info-rgb: 13, 202, 240; + --bs-warning-rgb: 255, 193, 7; + --bs-danger-rgb: 220, 53, 69; + --bs-light-rgb: 248, 249, 250; + --bs-dark-rgb: 33, 37, 41; + --bs-primary-text-emphasis: #052c65; + --bs-secondary-text-emphasis: #2b2f32; + --bs-success-text-emphasis: #0a3622; + --bs-info-text-emphasis: #055160; + --bs-warning-text-emphasis: #664d03; + --bs-danger-text-emphasis: #58151c; + --bs-light-text-emphasis: #495057; + --bs-dark-text-emphasis: #495057; + --bs-primary-bg-subtle: #cfe2ff; + --bs-secondary-bg-subtle: #e2e3e5; + --bs-success-bg-subtle: #d1e7dd; + --bs-info-bg-subtle: #cff4fc; + --bs-warning-bg-subtle: #fff3cd; + --bs-danger-bg-subtle: #f8d7da; + --bs-light-bg-subtle: #fcfcfd; + --bs-dark-bg-subtle: #ced4da; + --bs-primary-border-subtle: #9ec5fe; + --bs-secondary-border-subtle: #c4c8cb; + --bs-success-border-subtle: #a3cfbb; + --bs-info-border-subtle: #9eeaf9; + --bs-warning-border-subtle: #ffe69c; + --bs-danger-border-subtle: #f1aeb5; + --bs-light-border-subtle: #e9ecef; + --bs-dark-border-subtle: #adb5bd; + --bs-white-rgb: 255, 255, 255; + --bs-black-rgb: 0, 0, 0; + --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); + --bs-body-font-family: var(--bs-font-sans-serif); + --bs-body-font-size: 1rem; + --bs-body-font-weight: 400; + --bs-body-line-height: 1.5; + --bs-body-color: #212529; + --bs-body-color-rgb: 33, 37, 41; + --bs-body-bg: #fff; + --bs-body-bg-rgb: 255, 255, 255; + --bs-emphasis-color: #000; + --bs-emphasis-color-rgb: 0, 0, 0; + --bs-secondary-color: rgba(33, 37, 41, 0.75); + --bs-secondary-color-rgb: 33, 37, 41; + --bs-secondary-bg: #e9ecef; + --bs-secondary-bg-rgb: 233, 236, 239; + --bs-tertiary-color: rgba(33, 37, 41, 0.5); + --bs-tertiary-color-rgb: 33, 37, 41; + --bs-tertiary-bg: #f8f9fa; + --bs-tertiary-bg-rgb: 248, 249, 250; + --bs-heading-color: inherit; + --bs-link-color: #0d6efd; + --bs-link-color-rgb: 13, 110, 253; + --bs-link-decoration: underline; + --bs-link-hover-color: #0a58ca; + --bs-link-hover-color-rgb: 10, 88, 202; + --bs-code-color: #d63384; + --bs-highlight-color: #212529; + --bs-highlight-bg: #fff3cd; + --bs-border-width: 1px; + --bs-border-style: solid; + --bs-border-color: #dee2e6; + --bs-border-color-translucent: rgba(0, 0, 0, 0.175); + --bs-border-radius: 0.375rem; + --bs-border-radius-sm: 0.25rem; + --bs-border-radius-lg: 0.5rem; + --bs-border-radius-xl: 1rem; + --bs-border-radius-xxl: 2rem; + --bs-border-radius-2xl: var(--bs-border-radius-xxl); + --bs-border-radius-pill: 50rem; + --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); + --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075); + --bs-focus-ring-width: 0.25rem; + --bs-focus-ring-opacity: 0.25; + --bs-focus-ring-color: rgba(13, 110, 253, 0.25); + --bs-form-valid-color: #198754; + --bs-form-valid-border-color: #198754; + --bs-form-invalid-color: #dc3545; + --bs-form-invalid-border-color: #dc3545; +} + +[data-bs-theme=dark] { + color-scheme: dark; + --bs-body-color: #dee2e6; + --bs-body-color-rgb: 222, 226, 230; + --bs-body-bg: #212529; + --bs-body-bg-rgb: 33, 37, 41; + --bs-emphasis-color: #fff; + --bs-emphasis-color-rgb: 255, 255, 255; + --bs-secondary-color: rgba(222, 226, 230, 0.75); + --bs-secondary-color-rgb: 222, 226, 230; + --bs-secondary-bg: #343a40; + --bs-secondary-bg-rgb: 52, 58, 64; + --bs-tertiary-color: rgba(222, 226, 230, 0.5); + --bs-tertiary-color-rgb: 222, 226, 230; + --bs-tertiary-bg: #2b3035; + --bs-tertiary-bg-rgb: 43, 48, 53; + --bs-primary-text-emphasis: #6ea8fe; + --bs-secondary-text-emphasis: #a7acb1; + --bs-success-text-emphasis: #75b798; + --bs-info-text-emphasis: #6edff6; + --bs-warning-text-emphasis: #ffda6a; + --bs-danger-text-emphasis: #ea868f; + --bs-light-text-emphasis: #f8f9fa; + --bs-dark-text-emphasis: #dee2e6; + --bs-primary-bg-subtle: #031633; + --bs-secondary-bg-subtle: #161719; + --bs-success-bg-subtle: #051b11; + --bs-info-bg-subtle: #032830; + --bs-warning-bg-subtle: #332701; + --bs-danger-bg-subtle: #2c0b0e; + --bs-light-bg-subtle: #343a40; + --bs-dark-bg-subtle: #1a1d20; + --bs-primary-border-subtle: #084298; + --bs-secondary-border-subtle: #41464b; + --bs-success-border-subtle: #0f5132; + --bs-info-border-subtle: #087990; + --bs-warning-border-subtle: #997404; + --bs-danger-border-subtle: #842029; + --bs-light-border-subtle: #495057; + --bs-dark-border-subtle: #343a40; + --bs-heading-color: inherit; + --bs-link-color: #6ea8fe; + --bs-link-hover-color: #8bb9fe; + --bs-link-color-rgb: 110, 168, 254; + --bs-link-hover-color-rgb: 139, 185, 254; + --bs-code-color: #e685b5; + --bs-highlight-color: #dee2e6; + --bs-highlight-bg: #664d03; + --bs-border-color: #495057; + --bs-border-color-translucent: rgba(255, 255, 255, 0.15); + --bs-form-valid-color: #75b798; + --bs-form-valid-border-color: #75b798; + --bs-form-invalid-color: #ea868f; + --bs-form-invalid-border-color: #ea868f; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +@media (prefers-reduced-motion: no-preference) { + :root { + scroll-behavior: smooth; + } +} + +body { + margin: 0; + font-family: var(--bs-body-font-family); + font-size: var(--bs-body-font-size); + font-weight: var(--bs-body-font-weight); + line-height: var(--bs-body-line-height); + color: var(--bs-body-color); + text-align: var(--bs-body-text-align); + background-color: var(--bs-body-bg); + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} + +hr { + margin: 1rem 0; + color: inherit; + border: 0; + border-top: var(--bs-border-width) solid; + opacity: 0.25; +} + +h6, h5, h4, h3, h2, h1 { + margin-top: 0; + margin-bottom: 0.5rem; + font-weight: 500; + line-height: 1.2; + color: var(--bs-heading-color); +} + +h1 { + font-size: calc(1.375rem + 1.5vw); +} +@media (min-width: 1200px) { + h1 { + font-size: 2.5rem; + } +} + +h2 { + font-size: calc(1.325rem + 0.9vw); +} +@media (min-width: 1200px) { + h2 { + font-size: 2rem; + } +} + +h3 { + font-size: calc(1.3rem + 0.6vw); +} +@media (min-width: 1200px) { + h3 { + font-size: 1.75rem; + } +} + +h4 { + font-size: calc(1.275rem + 0.3vw); +} +@media (min-width: 1200px) { + h4 { + font-size: 1.5rem; + } +} + +h5 { + font-size: 1.25rem; +} + +h6 { + font-size: 1rem; +} + +p { + margin-top: 0; + margin-bottom: 1rem; +} + +abbr[title] { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + cursor: help; + -webkit-text-decoration-skip-ink: none; + text-decoration-skip-ink: none; +} + +address { + margin-bottom: 1rem; + font-style: normal; + line-height: inherit; +} + +ol, +ul { + padding-left: 2rem; +} + +ol, +ul, +dl { + margin-top: 0; + margin-bottom: 1rem; +} + +ol ol, +ul ul, +ol ul, +ul ol { + margin-bottom: 0; +} + +dt { + font-weight: 700; +} + +dd { + margin-bottom: 0.5rem; + margin-left: 0; +} + +blockquote { + margin: 0 0 1rem; +} + +b, +strong { + font-weight: bolder; +} + +small { + font-size: 0.875em; +} + +mark { + padding: 0.1875em; + color: var(--bs-highlight-color); + background-color: var(--bs-highlight-bg); +} + +sub, +sup { + position: relative; + font-size: 0.75em; + line-height: 0; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +a { + color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); + text-decoration: underline; +} +a:hover { + --bs-link-color-rgb: var(--bs-link-hover-color-rgb); +} + +a:not([href]):not([class]), a:not([href]):not([class]):hover { + color: inherit; + text-decoration: none; +} + +pre, +code, +kbd, +samp { + font-family: var(--bs-font-monospace); + font-size: 1em; +} + +pre { + display: block; + margin-top: 0; + margin-bottom: 1rem; + overflow: auto; + font-size: 0.875em; +} +pre code { + font-size: inherit; + color: inherit; + word-break: normal; +} + +code { + font-size: 0.875em; + color: var(--bs-code-color); + word-wrap: break-word; +} +a > code { + color: inherit; +} + +kbd { + padding: 0.1875rem 0.375rem; + font-size: 0.875em; + color: var(--bs-body-bg); + background-color: var(--bs-body-color); + border-radius: 0.25rem; +} +kbd kbd { + padding: 0; + font-size: 1em; +} + +figure { + margin: 0 0 1rem; +} + +img, +svg { + vertical-align: middle; +} + +table { + caption-side: bottom; + border-collapse: collapse; +} + +caption { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + color: var(--bs-secondary-color); + text-align: left; +} + +th { + text-align: inherit; + text-align: -webkit-match-parent; +} + +thead, +tbody, +tfoot, +tr, +td, +th { + border-color: inherit; + border-style: solid; + border-width: 0; +} + +label { + display: inline-block; +} + +button { + border-radius: 0; +} + +button:focus:not(:focus-visible) { + outline: 0; +} + +input, +button, +select, +optgroup, +textarea { + margin: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +button, +select { + text-transform: none; +} + +[role=button] { + cursor: pointer; +} + +select { + word-wrap: normal; +} +select:disabled { + opacity: 1; +} + +[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator { + display: none !important; +} + +button, +[type=button], +[type=reset], +[type=submit] { + -webkit-appearance: button; +} +button:not(:disabled), +[type=button]:not(:disabled), +[type=reset]:not(:disabled), +[type=submit]:not(:disabled) { + cursor: pointer; +} + +::-moz-focus-inner { + padding: 0; + border-style: none; +} + +textarea { + resize: vertical; +} + +fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} + +legend { + float: left; + width: 100%; + padding: 0; + margin-bottom: 0.5rem; + font-size: calc(1.275rem + 0.3vw); + line-height: inherit; +} +@media (min-width: 1200px) { + legend { + font-size: 1.5rem; + } +} +legend + * { + clear: left; +} + +::-webkit-datetime-edit-fields-wrapper, +::-webkit-datetime-edit-text, +::-webkit-datetime-edit-minute, +::-webkit-datetime-edit-hour-field, +::-webkit-datetime-edit-day-field, +::-webkit-datetime-edit-month-field, +::-webkit-datetime-edit-year-field { + padding: 0; +} + +::-webkit-inner-spin-button { + height: auto; +} + +[type=search] { + -webkit-appearance: textfield; + outline-offset: -2px; +} + +/* rtl:raw: +[type="tel"], +[type="url"], +[type="email"], +[type="number"] { + direction: ltr; +} +*/ +::-webkit-search-decoration { + -webkit-appearance: none; +} + +::-webkit-color-swatch-wrapper { + padding: 0; +} + +::-webkit-file-upload-button { + font: inherit; + -webkit-appearance: button; +} + +::file-selector-button { + font: inherit; + -webkit-appearance: button; +} + +output { + display: inline-block; +} + +iframe { + border: 0; +} + +summary { + display: list-item; + cursor: pointer; +} + +progress { + vertical-align: baseline; +} + +[hidden] { + display: none !important; +} + +/*# sourceMappingURL=bootstrap-reboot.css.map */ \ No newline at end of file diff --git a/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map new file mode 100644 index 0000000..5fe522b --- /dev/null +++ b/src/Werkr.Server/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_root.scss","../../scss/vendor/_rfs.scss","bootstrap-reboot.css","../../scss/mixins/_color-mode.scss","../../scss/_reboot.scss","../../scss/_variables.scss","../../scss/mixins/_border-radius.scss"],"names":[],"mappings":"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","file":"bootstrap-reboot.css","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n",":root,\n[data-bs-theme=\"light\"] {\n // Note: Custom variable values only support SassScript inside `#{}`.\n\n // Colors\n //\n // Generate palettes for full colors, grays, and theme colors.\n\n @each $color, $value in $colors {\n --#{$prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$prefix}#{$color}-rgb: #{$value};\n }\n\n @each $color, $value in $theme-colors-text {\n --#{$prefix}#{$color}-text-emphasis: #{$value};\n }\n\n @each $color, $value in $theme-colors-bg-subtle {\n --#{$prefix}#{$color}-bg-subtle: #{$value};\n }\n\n @each $color, $value in $theme-colors-border-subtle {\n --#{$prefix}#{$color}-border-subtle: #{$value};\n }\n\n --#{$prefix}white-rgb: #{to-rgb($white)};\n --#{$prefix}black-rgb: #{to-rgb($black)};\n\n // Fonts\n\n // Note: Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --#{$prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$prefix}gradient: #{$gradient};\n\n // Root and body\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$prefix}root-font-size: #{$font-size-root};\n }\n --#{$prefix}body-font-family: #{inspect($font-family-base)};\n @include rfs($font-size-base, --#{$prefix}body-font-size);\n --#{$prefix}body-font-weight: #{$font-weight-base};\n --#{$prefix}body-line-height: #{$line-height-base};\n @if $body-text-align != null {\n --#{$prefix}body-text-align: #{$body-text-align};\n }\n\n --#{$prefix}body-color: #{$body-color};\n --#{$prefix}body-color-rgb: #{to-rgb($body-color)};\n --#{$prefix}body-bg: #{$body-bg};\n --#{$prefix}body-bg-rgb: #{to-rgb($body-bg)};\n\n --#{$prefix}emphasis-color: #{$body-emphasis-color};\n --#{$prefix}emphasis-color-rgb: #{to-rgb($body-emphasis-color)};\n\n --#{$prefix}secondary-color: #{$body-secondary-color};\n --#{$prefix}secondary-color-rgb: #{to-rgb($body-secondary-color)};\n --#{$prefix}secondary-bg: #{$body-secondary-bg};\n --#{$prefix}secondary-bg-rgb: #{to-rgb($body-secondary-bg)};\n\n --#{$prefix}tertiary-color: #{$body-tertiary-color};\n --#{$prefix}tertiary-color-rgb: #{to-rgb($body-tertiary-color)};\n --#{$prefix}tertiary-bg: #{$body-tertiary-bg};\n --#{$prefix}tertiary-bg-rgb: #{to-rgb($body-tertiary-bg)};\n // scss-docs-end root-body-variables\n\n --#{$prefix}heading-color: #{$headings-color};\n\n --#{$prefix}link-color: #{$link-color};\n --#{$prefix}link-color-rgb: #{to-rgb($link-color)};\n --#{$prefix}link-decoration: #{$link-decoration};\n\n --#{$prefix}link-hover-color: #{$link-hover-color};\n --#{$prefix}link-hover-color-rgb: #{to-rgb($link-hover-color)};\n\n @if $link-hover-decoration != null {\n --#{$prefix}link-hover-decoration: #{$link-hover-decoration};\n }\n\n --#{$prefix}code-color: #{$code-color};\n --#{$prefix}highlight-color: #{$mark-color};\n --#{$prefix}highlight-bg: #{$mark-bg};\n\n // scss-docs-start root-border-var\n --#{$prefix}border-width: #{$border-width};\n --#{$prefix}border-style: #{$border-style};\n --#{$prefix}border-color: #{$border-color};\n --#{$prefix}border-color-translucent: #{$border-color-translucent};\n\n --#{$prefix}border-radius: #{$border-radius};\n --#{$prefix}border-radius-sm: #{$border-radius-sm};\n --#{$prefix}border-radius-lg: #{$border-radius-lg};\n --#{$prefix}border-radius-xl: #{$border-radius-xl};\n --#{$prefix}border-radius-xxl: #{$border-radius-xxl};\n --#{$prefix}border-radius-2xl: var(--#{$prefix}border-radius-xxl); // Deprecated in v5.3.0 for consistency\n --#{$prefix}border-radius-pill: #{$border-radius-pill};\n // scss-docs-end root-border-var\n\n --#{$prefix}box-shadow: #{$box-shadow};\n --#{$prefix}box-shadow-sm: #{$box-shadow-sm};\n --#{$prefix}box-shadow-lg: #{$box-shadow-lg};\n --#{$prefix}box-shadow-inset: #{$box-shadow-inset};\n\n // Focus styles\n // scss-docs-start root-focus-variables\n --#{$prefix}focus-ring-width: #{$focus-ring-width};\n --#{$prefix}focus-ring-opacity: #{$focus-ring-opacity};\n --#{$prefix}focus-ring-color: #{$focus-ring-color};\n // scss-docs-end root-focus-variables\n\n // scss-docs-start root-form-validation-variables\n --#{$prefix}form-valid-color: #{$form-valid-color};\n --#{$prefix}form-valid-border-color: #{$form-valid-border-color};\n --#{$prefix}form-invalid-color: #{$form-invalid-color};\n --#{$prefix}form-invalid-border-color: #{$form-invalid-border-color};\n // scss-docs-end root-form-validation-variables\n}\n\n@if $enable-dark-mode {\n @include color-mode(dark, true) {\n color-scheme: dark;\n\n // scss-docs-start root-dark-mode-vars\n --#{$prefix}body-color: #{$body-color-dark};\n --#{$prefix}body-color-rgb: #{to-rgb($body-color-dark)};\n --#{$prefix}body-bg: #{$body-bg-dark};\n --#{$prefix}body-bg-rgb: #{to-rgb($body-bg-dark)};\n\n --#{$prefix}emphasis-color: #{$body-emphasis-color-dark};\n --#{$prefix}emphasis-color-rgb: #{to-rgb($body-emphasis-color-dark)};\n\n --#{$prefix}secondary-color: #{$body-secondary-color-dark};\n --#{$prefix}secondary-color-rgb: #{to-rgb($body-secondary-color-dark)};\n --#{$prefix}secondary-bg: #{$body-secondary-bg-dark};\n --#{$prefix}secondary-bg-rgb: #{to-rgb($body-secondary-bg-dark)};\n\n --#{$prefix}tertiary-color: #{$body-tertiary-color-dark};\n --#{$prefix}tertiary-color-rgb: #{to-rgb($body-tertiary-color-dark)};\n --#{$prefix}tertiary-bg: #{$body-tertiary-bg-dark};\n --#{$prefix}tertiary-bg-rgb: #{to-rgb($body-tertiary-bg-dark)};\n\n @each $color, $value in $theme-colors-text-dark {\n --#{$prefix}#{$color}-text-emphasis: #{$value};\n }\n\n @each $color, $value in $theme-colors-bg-subtle-dark {\n --#{$prefix}#{$color}-bg-subtle: #{$value};\n }\n\n @each $color, $value in $theme-colors-border-subtle-dark {\n --#{$prefix}#{$color}-border-subtle: #{$value};\n }\n\n --#{$prefix}heading-color: #{$headings-color-dark};\n\n --#{$prefix}link-color: #{$link-color-dark};\n --#{$prefix}link-hover-color: #{$link-hover-color-dark};\n --#{$prefix}link-color-rgb: #{to-rgb($link-color-dark)};\n --#{$prefix}link-hover-color-rgb: #{to-rgb($link-hover-color-dark)};\n\n --#{$prefix}code-color: #{$code-color-dark};\n --#{$prefix}highlight-color: #{$mark-color-dark};\n --#{$prefix}highlight-bg: #{$mark-bg-dark};\n\n --#{$prefix}border-color: #{$border-color-dark};\n --#{$prefix}border-color-translucent: #{$border-color-translucent-dark};\n\n --#{$prefix}form-valid-color: #{$form-valid-color-dark};\n --#{$prefix}form-valid-border-color: #{$form-valid-border-color-dark};\n --#{$prefix}form-invalid-color: #{$form-invalid-color-dark};\n --#{$prefix}form-invalid-border-color: #{$form-invalid-border-color-dark};\n // scss-docs-end root-dark-mode-vars\n }\n}\n","// stylelint-disable scss/dimension-no-non-numeric-values\n\n// SCSS RFS mixin\n//\n// Automated responsive values for font sizes, paddings, margins and much more\n//\n// Licensed under MIT (https://github.com/twbs/rfs/blob/main/LICENSE)\n\n// Configuration\n\n// Base value\n$rfs-base-value: 1.25rem !default;\n$rfs-unit: rem !default;\n\n@if $rfs-unit != rem and $rfs-unit != px {\n @error \"`#{$rfs-unit}` is not a valid unit for $rfs-unit. Use `px` or `rem`.\";\n}\n\n// Breakpoint at where values start decreasing if screen width is smaller\n$rfs-breakpoint: 1200px !default;\n$rfs-breakpoint-unit: px !default;\n\n@if $rfs-breakpoint-unit != px and $rfs-breakpoint-unit != em and $rfs-breakpoint-unit != rem {\n @error \"`#{$rfs-breakpoint-unit}` is not a valid unit for $rfs-breakpoint-unit. Use `px`, `em` or `rem`.\";\n}\n\n// Resize values based on screen height and width\n$rfs-two-dimensional: false !default;\n\n// Factor of decrease\n$rfs-factor: 10 !default;\n\n@if type-of($rfs-factor) != number or $rfs-factor <= 1 {\n @error \"`#{$rfs-factor}` is not a valid $rfs-factor, it must be greater than 1.\";\n}\n\n// Mode. Possibilities: \"min-media-query\", \"max-media-query\"\n$rfs-mode: min-media-query !default;\n\n// Generate enable or disable classes. Possibilities: false, \"enable\" or \"disable\"\n$rfs-class: false !default;\n\n// 1 rem = $rfs-rem-value px\n$rfs-rem-value: 16 !default;\n\n// Safari iframe resize bug: https://github.com/twbs/rfs/issues/14\n$rfs-safari-iframe-resize-bug-fix: false !default;\n\n// Disable RFS by setting $enable-rfs to false\n$enable-rfs: true !default;\n\n// Cache $rfs-base-value unit\n$rfs-base-value-unit: unit($rfs-base-value);\n\n@function divide($dividend, $divisor, $precision: 10) {\n $sign: if($dividend > 0 and $divisor > 0 or $dividend < 0 and $divisor < 0, 1, -1);\n $dividend: abs($dividend);\n $divisor: abs($divisor);\n @if $dividend == 0 {\n @return 0;\n }\n @if $divisor == 0 {\n @error \"Cannot divide by 0\";\n }\n $remainder: $dividend;\n $result: 0;\n $factor: 10;\n @while ($remainder > 0 and $precision >= 0) {\n $quotient: 0;\n @while ($remainder >= $divisor) {\n $remainder: $remainder - $divisor;\n $quotient: $quotient + 1;\n }\n $result: $result * 10 + $quotient;\n $factor: $factor * .1;\n $remainder: $remainder * 10;\n $precision: $precision - 1;\n @if ($precision < 0 and $remainder >= $divisor * 5) {\n $result: $result + 1;\n }\n }\n $result: $result * $factor * $sign;\n $dividend-unit: unit($dividend);\n $divisor-unit: unit($divisor);\n $unit-map: (\n \"px\": 1px,\n \"rem\": 1rem,\n \"em\": 1em,\n \"%\": 1%\n );\n @if ($dividend-unit != $divisor-unit and map-has-key($unit-map, $dividend-unit)) {\n $result: $result * map-get($unit-map, $dividend-unit);\n }\n @return $result;\n}\n\n// Remove px-unit from $rfs-base-value for calculations\n@if $rfs-base-value-unit == px {\n $rfs-base-value: divide($rfs-base-value, $rfs-base-value * 0 + 1);\n}\n@else if $rfs-base-value-unit == rem {\n $rfs-base-value: divide($rfs-base-value, divide($rfs-base-value * 0 + 1, $rfs-rem-value));\n}\n\n// Cache $rfs-breakpoint unit to prevent multiple calls\n$rfs-breakpoint-unit-cache: unit($rfs-breakpoint);\n\n// Remove unit from $rfs-breakpoint for calculations\n@if $rfs-breakpoint-unit-cache == px {\n $rfs-breakpoint: divide($rfs-breakpoint, $rfs-breakpoint * 0 + 1);\n}\n@else if $rfs-breakpoint-unit-cache == rem or $rfs-breakpoint-unit-cache == \"em\" {\n $rfs-breakpoint: divide($rfs-breakpoint, divide($rfs-breakpoint * 0 + 1, $rfs-rem-value));\n}\n\n// Calculate the media query value\n$rfs-mq-value: if($rfs-breakpoint-unit == px, #{$rfs-breakpoint}px, #{divide($rfs-breakpoint, $rfs-rem-value)}#{$rfs-breakpoint-unit});\n$rfs-mq-property-width: if($rfs-mode == max-media-query, max-width, min-width);\n$rfs-mq-property-height: if($rfs-mode == max-media-query, max-height, min-height);\n\n// Internal mixin used to determine which media query needs to be used\n@mixin _rfs-media-query {\n @if $rfs-two-dimensional {\n @if $rfs-mode == max-media-query {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}), (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {\n @content;\n }\n }\n @else {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) and (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {\n @content;\n }\n }\n }\n @else {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) {\n @content;\n }\n }\n}\n\n// Internal mixin that adds disable classes to the selector if needed.\n@mixin _rfs-rule {\n @if $rfs-class == disable and $rfs-mode == max-media-query {\n // Adding an extra class increases specificity, which prevents the media query to override the property\n &,\n .disable-rfs &,\n &.disable-rfs {\n @content;\n }\n }\n @else if $rfs-class == enable and $rfs-mode == min-media-query {\n .enable-rfs &,\n &.enable-rfs {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Internal mixin that adds enable classes to the selector if needed.\n@mixin _rfs-media-query-rule {\n\n @if $rfs-class == enable {\n @if $rfs-mode == min-media-query {\n @content;\n }\n\n @include _rfs-media-query () {\n .enable-rfs &,\n &.enable-rfs {\n @content;\n }\n }\n }\n @else {\n @if $rfs-class == disable and $rfs-mode == min-media-query {\n .disable-rfs &,\n &.disable-rfs {\n @content;\n }\n }\n @include _rfs-media-query () {\n @content;\n }\n }\n}\n\n// Helper function to get the formatted non-responsive value\n@function rfs-value($values) {\n // Convert to list\n $values: if(type-of($values) != list, ($values,), $values);\n\n $val: \"\";\n\n // Loop over each value and calculate value\n @each $value in $values {\n @if $value == 0 {\n $val: $val + \" 0\";\n }\n @else {\n // Cache $value unit\n $unit: if(type-of($value) == \"number\", unit($value), false);\n\n @if $unit == px {\n // Convert to rem if needed\n $val: $val + \" \" + if($rfs-unit == rem, #{divide($value, $value * 0 + $rfs-rem-value)}rem, $value);\n }\n @else if $unit == rem {\n // Convert to px if needed\n $val: $val + \" \" + if($rfs-unit == px, #{divide($value, $value * 0 + 1) * $rfs-rem-value}px, $value);\n } @else {\n // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n $val: $val + \" \" + $value;\n }\n }\n }\n\n // Remove first space\n @return unquote(str-slice($val, 2));\n}\n\n// Helper function to get the responsive value calculated by RFS\n@function rfs-fluid-value($values) {\n // Convert to list\n $values: if(type-of($values) != list, ($values,), $values);\n\n $val: \"\";\n\n // Loop over each value and calculate value\n @each $value in $values {\n @if $value == 0 {\n $val: $val + \" 0\";\n } @else {\n // Cache $value unit\n $unit: if(type-of($value) == \"number\", unit($value), false);\n\n // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n @if not $unit or $unit != px and $unit != rem {\n $val: $val + \" \" + $value;\n } @else {\n // Remove unit from $value for calculations\n $value: divide($value, $value * 0 + if($unit == px, 1, divide(1, $rfs-rem-value)));\n\n // Only add the media query if the value is greater than the minimum value\n @if abs($value) <= $rfs-base-value or not $enable-rfs {\n $val: $val + \" \" + if($rfs-unit == rem, #{divide($value, $rfs-rem-value)}rem, #{$value}px);\n }\n @else {\n // Calculate the minimum value\n $value-min: $rfs-base-value + divide(abs($value) - $rfs-base-value, $rfs-factor);\n\n // Calculate difference between $value and the minimum value\n $value-diff: abs($value) - $value-min;\n\n // Base value formatting\n $min-width: if($rfs-unit == rem, #{divide($value-min, $rfs-rem-value)}rem, #{$value-min}px);\n\n // Use negative value if needed\n $min-width: if($value < 0, -$min-width, $min-width);\n\n // Use `vmin` if two-dimensional is enabled\n $variable-unit: if($rfs-two-dimensional, vmin, vw);\n\n // Calculate the variable width between 0 and $rfs-breakpoint\n $variable-width: #{divide($value-diff * 100, $rfs-breakpoint)}#{$variable-unit};\n\n // Return the calculated value\n $val: $val + \" calc(\" + $min-width + if($value < 0, \" - \", \" + \") + $variable-width + \")\";\n }\n }\n }\n }\n\n // Remove first space\n @return unquote(str-slice($val, 2));\n}\n\n// RFS mixin\n@mixin rfs($values, $property: font-size) {\n @if $values != null {\n $val: rfs-value($values);\n $fluid-val: rfs-fluid-value($values);\n\n // Do not print the media query if responsive & non-responsive values are the same\n @if $val == $fluid-val {\n #{$property}: $val;\n }\n @else {\n @include _rfs-rule () {\n #{$property}: if($rfs-mode == max-media-query, $val, $fluid-val);\n\n // Include safari iframe resize fix if needed\n min-width: if($rfs-safari-iframe-resize-bug-fix, (0 * 1vw), null);\n }\n\n @include _rfs-media-query-rule () {\n #{$property}: if($rfs-mode == max-media-query, $fluid-val, $val);\n }\n }\n }\n}\n\n// Shorthand helper mixins\n@mixin font-size($value) {\n @include rfs($value);\n}\n\n@mixin padding($value) {\n @include rfs($value, padding);\n}\n\n@mixin padding-top($value) {\n @include rfs($value, padding-top);\n}\n\n@mixin padding-right($value) {\n @include rfs($value, padding-right);\n}\n\n@mixin padding-bottom($value) {\n @include rfs($value, padding-bottom);\n}\n\n@mixin padding-left($value) {\n @include rfs($value, padding-left);\n}\n\n@mixin margin($value) {\n @include rfs($value, margin);\n}\n\n@mixin margin-top($value) {\n @include rfs($value, margin-top);\n}\n\n@mixin margin-right($value) {\n @include rfs($value, margin-right);\n}\n\n@mixin margin-bottom($value) {\n @include rfs($value, margin-bottom);\n}\n\n@mixin margin-left($value) {\n @include rfs($value, margin-left);\n}\n","/*!\n * Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n:root,\n[data-bs-theme=light] {\n --bs-blue: #0d6efd;\n --bs-indigo: #6610f2;\n --bs-purple: #6f42c1;\n --bs-pink: #d63384;\n --bs-red: #dc3545;\n --bs-orange: #fd7e14;\n --bs-yellow: #ffc107;\n --bs-green: #198754;\n --bs-teal: #20c997;\n --bs-cyan: #0dcaf0;\n --bs-black: #000;\n --bs-white: #fff;\n --bs-gray: #6c757d;\n --bs-gray-dark: #343a40;\n --bs-gray-100: #f8f9fa;\n --bs-gray-200: #e9ecef;\n --bs-gray-300: #dee2e6;\n --bs-gray-400: #ced4da;\n --bs-gray-500: #adb5bd;\n --bs-gray-600: #6c757d;\n --bs-gray-700: #495057;\n --bs-gray-800: #343a40;\n --bs-gray-900: #212529;\n --bs-primary: #0d6efd;\n --bs-secondary: #6c757d;\n --bs-success: #198754;\n --bs-info: #0dcaf0;\n --bs-warning: #ffc107;\n --bs-danger: #dc3545;\n --bs-light: #f8f9fa;\n --bs-dark: #212529;\n --bs-primary-rgb: 13, 110, 253;\n --bs-secondary-rgb: 108, 117, 125;\n --bs-success-rgb: 25, 135, 84;\n --bs-info-rgb: 13, 202, 240;\n --bs-warning-rgb: 255, 193, 7;\n --bs-danger-rgb: 220, 53, 69;\n --bs-light-rgb: 248, 249, 250;\n --bs-dark-rgb: 33, 37, 41;\n --bs-primary-text-emphasis: #052c65;\n --bs-secondary-text-emphasis: #2b2f32;\n --bs-success-text-emphasis: #0a3622;\n --bs-info-text-emphasis: #055160;\n --bs-warning-text-emphasis: #664d03;\n --bs-danger-text-emphasis: #58151c;\n --bs-light-text-emphasis: #495057;\n --bs-dark-text-emphasis: #495057;\n --bs-primary-bg-subtle: #cfe2ff;\n --bs-secondary-bg-subtle: #e2e3e5;\n --bs-success-bg-subtle: #d1e7dd;\n --bs-info-bg-subtle: #cff4fc;\n --bs-warning-bg-subtle: #fff3cd;\n --bs-danger-bg-subtle: #f8d7da;\n --bs-light-bg-subtle: #fcfcfd;\n --bs-dark-bg-subtle: #ced4da;\n --bs-primary-border-subtle: #9ec5fe;\n --bs-secondary-border-subtle: #c4c8cb;\n --bs-success-border-subtle: #a3cfbb;\n --bs-info-border-subtle: #9eeaf9;\n --bs-warning-border-subtle: #ffe69c;\n --bs-danger-border-subtle: #f1aeb5;\n --bs-light-border-subtle: #e9ecef;\n --bs-dark-border-subtle: #adb5bd;\n --bs-white-rgb: 255, 255, 255;\n --bs-black-rgb: 0, 0, 0;\n --bs-font-sans-serif: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));\n --bs-body-font-family: var(--bs-font-sans-serif);\n --bs-body-font-size: 1rem;\n --bs-body-font-weight: 400;\n --bs-body-line-height: 1.5;\n --bs-body-color: #212529;\n --bs-body-color-rgb: 33, 37, 41;\n --bs-body-bg: #fff;\n --bs-body-bg-rgb: 255, 255, 255;\n --bs-emphasis-color: #000;\n --bs-emphasis-color-rgb: 0, 0, 0;\n --bs-secondary-color: rgba(33, 37, 41, 0.75);\n --bs-secondary-color-rgb: 33, 37, 41;\n --bs-secondary-bg: #e9ecef;\n --bs-secondary-bg-rgb: 233, 236, 239;\n --bs-tertiary-color: rgba(33, 37, 41, 0.5);\n --bs-tertiary-color-rgb: 33, 37, 41;\n --bs-tertiary-bg: #f8f9fa;\n --bs-tertiary-bg-rgb: 248, 249, 250;\n --bs-heading-color: inherit;\n --bs-link-color: #0d6efd;\n --bs-link-color-rgb: 13, 110, 253;\n --bs-link-decoration: underline;\n --bs-link-hover-color: #0a58ca;\n --bs-link-hover-color-rgb: 10, 88, 202;\n --bs-code-color: #d63384;\n --bs-highlight-color: #212529;\n --bs-highlight-bg: #fff3cd;\n --bs-border-width: 1px;\n --bs-border-style: solid;\n --bs-border-color: #dee2e6;\n --bs-border-color-translucent: rgba(0, 0, 0, 0.175);\n --bs-border-radius: 0.375rem;\n --bs-border-radius-sm: 0.25rem;\n --bs-border-radius-lg: 0.5rem;\n --bs-border-radius-xl: 1rem;\n --bs-border-radius-xxl: 2rem;\n --bs-border-radius-2xl: var(--bs-border-radius-xxl);\n --bs-border-radius-pill: 50rem;\n --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);\n --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);\n --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);\n --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);\n --bs-focus-ring-width: 0.25rem;\n --bs-focus-ring-opacity: 0.25;\n --bs-focus-ring-color: rgba(13, 110, 253, 0.25);\n --bs-form-valid-color: #198754;\n --bs-form-valid-border-color: #198754;\n --bs-form-invalid-color: #dc3545;\n --bs-form-invalid-border-color: #dc3545;\n}\n\n[data-bs-theme=dark] {\n color-scheme: dark;\n --bs-body-color: #dee2e6;\n --bs-body-color-rgb: 222, 226, 230;\n --bs-body-bg: #212529;\n --bs-body-bg-rgb: 33, 37, 41;\n --bs-emphasis-color: #fff;\n --bs-emphasis-color-rgb: 255, 255, 255;\n --bs-secondary-color: rgba(222, 226, 230, 0.75);\n --bs-secondary-color-rgb: 222, 226, 230;\n --bs-secondary-bg: #343a40;\n --bs-secondary-bg-rgb: 52, 58, 64;\n --bs-tertiary-color: rgba(222, 226, 230, 0.5);\n --bs-tertiary-color-rgb: 222, 226, 230;\n --bs-tertiary-bg: #2b3035;\n --bs-tertiary-bg-rgb: 43, 48, 53;\n --bs-primary-text-emphasis: #6ea8fe;\n --bs-secondary-text-emphasis: #a7acb1;\n --bs-success-text-emphasis: #75b798;\n --bs-info-text-emphasis: #6edff6;\n --bs-warning-text-emphasis: #ffda6a;\n --bs-danger-text-emphasis: #ea868f;\n --bs-light-text-emphasis: #f8f9fa;\n --bs-dark-text-emphasis: #dee2e6;\n --bs-primary-bg-subtle: #031633;\n --bs-secondary-bg-subtle: #161719;\n --bs-success-bg-subtle: #051b11;\n --bs-info-bg-subtle: #032830;\n --bs-warning-bg-subtle: #332701;\n --bs-danger-bg-subtle: #2c0b0e;\n --bs-light-bg-subtle: #343a40;\n --bs-dark-bg-subtle: #1a1d20;\n --bs-primary-border-subtle: #084298;\n --bs-secondary-border-subtle: #41464b;\n --bs-success-border-subtle: #0f5132;\n --bs-info-border-subtle: #087990;\n --bs-warning-border-subtle: #997404;\n --bs-danger-border-subtle: #842029;\n --bs-light-border-subtle: #495057;\n --bs-dark-border-subtle: #343a40;\n --bs-heading-color: inherit;\n --bs-link-color: #6ea8fe;\n --bs-link-hover-color: #8bb9fe;\n --bs-link-color-rgb: 110, 168, 254;\n --bs-link-hover-color-rgb: 139, 185, 254;\n --bs-code-color: #e685b5;\n --bs-highlight-color: #dee2e6;\n --bs-highlight-bg: #664d03;\n --bs-border-color: #495057;\n --bs-border-color-translucent: rgba(255, 255, 255, 0.15);\n --bs-form-valid-color: #75b798;\n --bs-form-valid-border-color: #75b798;\n --bs-form-invalid-color: #ea868f;\n --bs-form-invalid-border-color: #ea868f;\n}\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n :root {\n scroll-behavior: smooth;\n }\n}\n\nbody {\n margin: 0;\n font-family: var(--bs-body-font-family);\n font-size: var(--bs-body-font-size);\n font-weight: var(--bs-body-font-weight);\n line-height: var(--bs-body-line-height);\n color: var(--bs-body-color);\n text-align: var(--bs-body-text-align);\n background-color: var(--bs-body-bg);\n -webkit-text-size-adjust: 100%;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\nhr {\n margin: 1rem 0;\n color: inherit;\n border: 0;\n border-top: var(--bs-border-width) solid;\n opacity: 0.25;\n}\n\nh6, h5, h4, h3, h2, h1 {\n margin-top: 0;\n margin-bottom: 0.5rem;\n font-weight: 500;\n line-height: 1.2;\n color: var(--bs-heading-color);\n}\n\nh1 {\n font-size: calc(1.375rem + 1.5vw);\n}\n@media (min-width: 1200px) {\n h1 {\n font-size: 2.5rem;\n }\n}\n\nh2 {\n font-size: calc(1.325rem + 0.9vw);\n}\n@media (min-width: 1200px) {\n h2 {\n font-size: 2rem;\n }\n}\n\nh3 {\n font-size: calc(1.3rem + 0.6vw);\n}\n@media (min-width: 1200px) {\n h3 {\n font-size: 1.75rem;\n }\n}\n\nh4 {\n font-size: calc(1.275rem + 0.3vw);\n}\n@media (min-width: 1200px) {\n h4 {\n font-size: 1.5rem;\n }\n}\n\nh5 {\n font-size: 1.25rem;\n}\n\nh6 {\n font-size: 1rem;\n}\n\np {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nabbr[title] {\n text-decoration: underline dotted;\n cursor: help;\n text-decoration-skip-ink: none;\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: 700;\n}\n\ndd {\n margin-bottom: 0.5rem;\n margin-left: 0;\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: bolder;\n}\n\nsmall {\n font-size: 0.875em;\n}\n\nmark {\n padding: 0.1875em;\n color: var(--bs-highlight-color);\n background-color: var(--bs-highlight-bg);\n}\n\nsub,\nsup {\n position: relative;\n font-size: 0.75em;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\na {\n color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));\n text-decoration: underline;\n}\na:hover {\n --bs-link-color-rgb: var(--bs-link-hover-color-rgb);\n}\n\na:not([href]):not([class]), a:not([href]):not([class]):hover {\n color: inherit;\n text-decoration: none;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: var(--bs-font-monospace);\n font-size: 1em;\n}\n\npre {\n display: block;\n margin-top: 0;\n margin-bottom: 1rem;\n overflow: auto;\n font-size: 0.875em;\n}\npre code {\n font-size: inherit;\n color: inherit;\n word-break: normal;\n}\n\ncode {\n font-size: 0.875em;\n color: var(--bs-code-color);\n word-wrap: break-word;\n}\na > code {\n color: inherit;\n}\n\nkbd {\n padding: 0.1875rem 0.375rem;\n font-size: 0.875em;\n color: var(--bs-body-bg);\n background-color: var(--bs-body-color);\n border-radius: 0.25rem;\n}\nkbd kbd {\n padding: 0;\n font-size: 1em;\n}\n\nfigure {\n margin: 0 0 1rem;\n}\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n color: var(--bs-secondary-color);\n text-align: left;\n}\n\nth {\n text-align: inherit;\n text-align: -webkit-match-parent;\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\nlabel {\n display: inline-block;\n}\n\nbutton {\n border-radius: 0;\n}\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\nselect {\n text-transform: none;\n}\n\n[role=button] {\n cursor: pointer;\n}\n\nselect {\n word-wrap: normal;\n}\nselect:disabled {\n opacity: 1;\n}\n\n[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {\n display: none !important;\n}\n\nbutton,\n[type=button],\n[type=reset],\n[type=submit] {\n -webkit-appearance: button;\n}\nbutton:not(:disabled),\n[type=button]:not(:disabled),\n[type=reset]:not(:disabled),\n[type=submit]:not(:disabled) {\n cursor: pointer;\n}\n\n::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ntextarea {\n resize: vertical;\n}\n\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\n\nlegend {\n float: left;\n width: 100%;\n padding: 0;\n margin-bottom: 0.5rem;\n font-size: calc(1.275rem + 0.3vw);\n line-height: inherit;\n}\n@media (min-width: 1200px) {\n legend {\n font-size: 1.5rem;\n }\n}\nlegend + * {\n clear: left;\n}\n\n::-webkit-datetime-edit-fields-wrapper,\n::-webkit-datetime-edit-text,\n::-webkit-datetime-edit-minute,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-year-field {\n padding: 0;\n}\n\n::-webkit-inner-spin-button {\n height: auto;\n}\n\n[type=search] {\n -webkit-appearance: textfield;\n outline-offset: -2px;\n}\n\n/* rtl:raw:\n[type=\"tel\"],\n[type=\"url\"],\n[type=\"email\"],\n[type=\"number\"] {\n direction: ltr;\n}\n*/\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n::-webkit-color-swatch-wrapper {\n padding: 0;\n}\n\n::file-selector-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\noutput {\n display: inline-block;\n}\n\niframe {\n border: 0;\n}\n\nsummary {\n display: list-item;\n cursor: pointer;\n}\n\nprogress {\n vertical-align: baseline;\n}\n\n[hidden] {\n display: none !important;\n}\n\n/*# sourceMappingURL=bootstrap-reboot.css.map */\n","// scss-docs-start color-mode-mixin\n@mixin color-mode($mode: light, $root: false) {\n @if $color-mode-type == \"media-query\" {\n @if $root == true {\n @media (prefers-color-scheme: $mode) {\n :root {\n @content;\n }\n }\n } @else {\n @media (prefers-color-scheme: $mode) {\n @content;\n }\n }\n } @else {\n [data-bs-theme=\"#{$mode}\"] {\n @content;\n }\n }\n}\n// scss-docs-end color-mode-mixin\n","// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n\n// Root\n//\n// Ability to the value of the root font sizes, affecting the value of `rem`.\n// null by default, thus nothing is generated.\n\n:root {\n @if $font-size-root != null {\n @include font-size(var(--#{$prefix}root-font-size));\n }\n\n @if $enable-smooth-scroll {\n @media (prefers-reduced-motion: no-preference) {\n scroll-behavior: smooth;\n }\n }\n}\n\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Prevent adjustments of font size after orientation changes in iOS.\n// 4. Change the default tap highlight to be completely transparent in iOS.\n\n// scss-docs-start reboot-body-rules\nbody {\n margin: 0; // 1\n font-family: var(--#{$prefix}body-font-family);\n @include font-size(var(--#{$prefix}body-font-size));\n font-weight: var(--#{$prefix}body-font-weight);\n line-height: var(--#{$prefix}body-line-height);\n color: var(--#{$prefix}body-color);\n text-align: var(--#{$prefix}body-text-align);\n background-color: var(--#{$prefix}body-bg); // 2\n -webkit-text-size-adjust: 100%; // 3\n -webkit-tap-highlight-color: rgba($black, 0); // 4\n}\n// scss-docs-end reboot-body-rules\n\n\n// Content grouping\n//\n// 1. Reset Firefox's gray color\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n border: 0;\n border-top: $hr-border-width solid $hr-border-color;\n opacity: $hr-opacity;\n}\n\n\n// Typography\n//\n// 1. Remove top margins from headings\n// By default, `

`-`
` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n\n%heading {\n margin-top: 0; // 1\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-style: $headings-font-style;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: var(--#{$prefix}heading-color);\n}\n\nh1 {\n @extend %heading;\n @include font-size($h1-font-size);\n}\n\nh2 {\n @extend %heading;\n @include font-size($h2-font-size);\n}\n\nh3 {\n @extend %heading;\n @include font-size($h3-font-size);\n}\n\nh4 {\n @extend %heading;\n @include font-size($h4-font-size);\n}\n\nh5 {\n @extend %heading;\n @include font-size($h5-font-size);\n}\n\nh6 {\n @extend %heading;\n @include font-size($h6-font-size);\n}\n\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 2. Add explicit cursor to indicate changed behavior.\n// 3. Prevent the text-decoration to be skipped.\n\nabbr[title] {\n text-decoration: underline dotted; // 1\n cursor: help; // 2\n text-decoration-skip-ink: none; // 3\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n color: var(--#{$prefix}highlight-color);\n background-color: var(--#{$prefix}highlight-bg);\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: rgba(var(--#{$prefix}link-color-rgb), var(--#{$prefix}link-opacity, 1));\n text-decoration: $link-decoration;\n\n &:hover {\n --#{$prefix}link-color-rgb: var(--#{$prefix}link-hover-color-rgb);\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: var(--#{$prefix}code-color);\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\n\n// Forms\n//\n// 1. Allow labels to use `margin` for spacing.\n\nlabel {\n display: inline-block; // 1\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n// See https://github.com/twbs/bootstrap/issues/24093\n\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\n// 1. Remove the margin in Firefox and Safari\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // 1\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\n// Remove the inheritance of text transform in Firefox\nbutton,\nselect {\n text-transform: none;\n}\n// Set the cursor for non-` - - -@if ( !string.IsNullOrEmpty( _errorMessage ) ) { -

@_errorMessage
-} - -@if ( _agents is not null && _agents.Count > 0 ) { -
- - - - - - - - - - - - - @foreach ( AgentListDto agent in _agents ) { - - - - - - - - - } - -
NameURLStatusLast SeenRegistered AtActions
@agent.ConnectionName@agent.RemoteUrl - @agent.Status - @( agent.LastSeen?.ToString( "g" ) ?? "—" )@agent.RegisteredAt.ToString( "g" ) - View Details - @if ( agent.Status == "Connected" ) { - - - - - - } - @if ( agent.Status != "Revoked" ) { - - } -
-
-} else if ( !_isLoading ) { -
- No agents registered yet. Click "Register New Agent" to get started. -
-} - -@code { - private List? _agents; - private string? _errorMessage; - private bool _isLoading; - private readonly List _breadcrumbs = [ - new( "Home", "/" ), - new( "Agents" ) - ]; - - /// - protected override async Task OnInitializedAsync( ) { - await LoadAgents( ); - } - - private async Task LoadAgents( ) { - _isLoading = true; - _errorMessage = null; - try { - HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); - _agents = await client.GetFromJsonAsync>( "/api/agents" ); - } catch ( Exception ex ) { - _errorMessage = $"Failed to load agents: {ex.Message}"; - } finally { - _isLoading = false; - } - } - - private async Task RevokeAgent( Guid agentId ) { - try { - HttpClient client = HttpClientFactory.CreateClient( "ApiService" ); - HttpResponseMessage response = await client.PostAsync( $"/api/agents/{agentId}/revoke", null ); - if ( response.IsSuccessStatusCode ) { - await LoadAgents( ); - } else { - _errorMessage = $"Failed to revoke agent: {(int) response.StatusCode}"; - } - } catch ( Exception ex ) { - _errorMessage = $"Failed to revoke agent: {ex.Message}"; - } - } - - private static string GetStatusBadgeClass( string status ) => AgentDisplayHelper.GetStatusBadgeClass( status ); - -} diff --git a/src/Werkr.Server/Endpoints/AuthEndpoints.cs b/src/Werkr.Server/Endpoints/AuthEndpoints.cs index 6a9ef28..1129b9f 100644 --- a/src/Werkr.Server/Endpoints/AuthEndpoints.cs +++ b/src/Werkr.Server/Endpoints/AuthEndpoints.cs @@ -1,5 +1,4 @@ using System.Security.Claims; - using Werkr.Common.Auth; using Werkr.Common.Models; using Werkr.Data.Identity.Entities; @@ -24,22 +23,23 @@ public static WebApplication MapAuthEndpoints( this WebApplication app ) { ApiKeyService apiKeyService, JwtTokenService tokenService, IPermissionService permissionService, - CancellationToken ct ) => { - if (string.IsNullOrWhiteSpace( request.ApiKey )) { - return Results.BadRequest( new { message = "API key is required." } ); - } + CancellationToken ct + ) => { + if (string.IsNullOrWhiteSpace( request.ApiKey )) { + return Results.BadRequest( new { message = "API key is required." } ); + } - ApiKey? apiKey = await apiKeyService.ValidateAsync( request.ApiKey, ct ); - if (apiKey is null) { - return Results.Unauthorized( ); - } + ApiKey? apiKey = await apiKeyService.ValidateAsync( request.ApiKey, ct ); + if (apiKey is null) { + return Results.Unauthorized( ); + } - IReadOnlyList permissions = + IReadOnlyList permissions = await permissionService.GetPermissionsForRoleAsync( apiKey.Role, ct ); - string token = tokenService.GenerateToken( apiKey, permissions ); - DateTime expiresUtc = DateTime.UtcNow.AddMinutes( 15 ); - return Results.Ok( new TokenResponse( token, expiresUtc ) ); - } ) + string token = tokenService.GenerateToken( apiKey, permissions ); + DateTime expiresUtc = DateTime.UtcNow.AddMinutes( 15 ); + return Results.Ok( new TokenResponse( token, expiresUtc ) ); + } ) .WithName( "ExchangeApiKeyForToken" ) .WithTags( "Auth" ) .AllowAnonymous( ); @@ -50,37 +50,42 @@ public static WebApplication MapAuthEndpoints( this WebApplication app ) { ApiKeyCreateRequest request, ApiKeyService apiKeyService, ClaimsPrincipal user, - CancellationToken ct ) => { - string? userId = user.FindFirst( ClaimTypes.NameIdentifier )?.Value; - if (string.IsNullOrWhiteSpace( userId )) { - return Results.Unauthorized( ); - } + CancellationToken ct + ) => { + string? userId = user.FindFirst( ClaimTypes.NameIdentifier )?.Value; + if (string.IsNullOrWhiteSpace( userId )) { + return Results.Unauthorized( ); + } - string? userRole = user.FindFirst( ClaimTypes.Role )?.Value; - if (string.IsNullOrWhiteSpace( userRole )) { - return Results.Forbid( ); - } + string? userRole = user.FindFirst( ClaimTypes.Role )?.Value; + if (string.IsNullOrWhiteSpace( userRole )) { + return Results.Forbid( ); + } - (ApiKey apiKey, string rawKey) = await apiKeyService.CreateAsync( - request.Name, userRole, userId, request.ExpiresUtc, ct ); + (ApiKey apiKey, string rawKey) = await apiKeyService.CreateAsync( + request.Name, userRole, userId, request.ExpiresUtc, ct + ); - return Results.Created( $"/api/auth/keys/{apiKey.Id}", new ApiKeyCreateResponse( + return Results.Created( $"/api/auth/keys/{apiKey.Id}", new ApiKeyCreateResponse( apiKey.Id, apiKey.Name, rawKey, apiKey.KeyPrefix, apiKey.Role, - apiKey.CreatedUtc, apiKey.ExpiresUtc ) ); - } ) + apiKey.CreatedUtc, apiKey.ExpiresUtc + ) ); + } ) .WithName( "CreateApiKey" ) .WithTags( "Auth" ) .RequireAuthorization( Policies.IsAdmin ); _ = app.MapGet( "/api/auth/keys", async ( ApiKeyService apiKeyService, - CancellationToken ct ) => { - IReadOnlyList keys = await apiKeyService.GetAllAsync( ct ); - List dtos = [.. keys.Select( k => new ApiKeyDto( + CancellationToken ct + ) => { + IReadOnlyList keys = await apiKeyService.GetAllAsync( ct ); + List dtos = [.. keys.Select( k => new ApiKeyDto( k.Id, k.Name, k.KeyPrefix, k.Role, k.CreatedByUserId, - k.CreatedUtc, k.ExpiresUtc, k.IsRevoked, k.LastUsedUtc ) )]; - return Results.Ok( dtos ); - } ) + k.CreatedUtc, k.ExpiresUtc, k.IsRevoked, k.LastUsedUtc + ) )]; + return Results.Ok( dtos ); + } ) .WithName( "ListApiKeys" ) .WithTags( "Auth" ) .RequireAuthorization( Policies.IsAdmin ); @@ -88,10 +93,11 @@ public static WebApplication MapAuthEndpoints( this WebApplication app ) { _ = app.MapDelete( "/api/auth/keys/{id}", async ( Guid id, ApiKeyService apiKeyService, - CancellationToken ct ) => { - bool revoked = await apiKeyService.RevokeAsync( id, ct ); - return revoked ? Results.NoContent( ) : Results.NotFound( ); - } ) + CancellationToken ct + ) => { + bool revoked = await apiKeyService.RevokeAsync( id, ct ); + return revoked ? Results.NoContent( ) : Results.NotFound( ); + } ) .WithName( "RevokeApiKey" ) .WithTags( "Auth" ) .RequireAuthorization( Policies.IsAdmin ); diff --git a/src/Werkr.Server/Helpers/AgentDisplayHelper.cs b/src/Werkr.Server/Helpers/AgentDisplayHelper.cs index aa36074..d1c12da 100644 --- a/src/Werkr.Server/Helpers/AgentDisplayHelper.cs +++ b/src/Werkr.Server/Helpers/AgentDisplayHelper.cs @@ -2,8 +2,8 @@ namespace Werkr.Server.Helpers; /// /// Consolidated UI helpers for agent status display. -/// Replaces duplicated GetStatusBadgeClass, FormatAvailability, -/// and FormatRelativeTime methods spread across multiple pages. +/// Replaces duplicated , , +/// and methods spread across multiple pages. /// public static class AgentDisplayHelper { diff --git a/src/Werkr.Server/Identity/ApiKeyService.cs b/src/Werkr.Server/Identity/ApiKeyService.cs index 5e16308..062696b 100644 --- a/src/Werkr.Server/Identity/ApiKeyService.cs +++ b/src/Werkr.Server/Identity/ApiKeyService.cs @@ -1,7 +1,5 @@ using System.Security.Cryptography; - using Microsoft.EntityFrameworkCore; - using Werkr.Data.Identity; using Werkr.Data.Identity.Entities; @@ -27,7 +25,8 @@ public sealed class ApiKeyService( WerkrIdentityDbContext dbContext, ILogger /// The raw API key to validate. /// Cancellation token. - /// The API key entity if valid; otherwise null. + /// The API key entity if valid; otherwise . public async Task ValidateAsync( string rawKey, CancellationToken ct = default ) { if (string.IsNullOrWhiteSpace( rawKey )) { return null; @@ -81,13 +81,15 @@ public sealed class ApiKeyService( WerkrIdentityDbContext dbContext, ILogger /// The API key ID. /// Cancellation token. - /// true if the key was found and revoked; otherwise false. + /// if the key was found and revoked; otherwise . public async Task RevokeAsync( Guid keyId, CancellationToken ct = default ) { ApiKey? apiKey = await dbContext.ApiKeys.FindAsync( [keyId], ct ); if (apiKey is null) { diff --git a/src/Werkr.Server/Identity/AuthForwardingHandler.cs b/src/Werkr.Server/Identity/AuthForwardingHandler.cs index aad9bde..fa043ee 100644 --- a/src/Werkr.Server/Identity/AuthForwardingHandler.cs +++ b/src/Werkr.Server/Identity/AuthForwardingHandler.cs @@ -5,10 +5,16 @@ namespace Werkr.Server.Identity; /// /// Delegating handler that attaches a self-minted JWT bearer token to /// outgoing API requests from the Blazor Server. The Server is the sole -/// JWT issuer and trusts itself — no HTTP round-trip is needed (Decision A1). +/// JWT issuer and trusts itself - no HTTP round-trip is needed (Decision A1). /// public sealed class AuthForwardingHandler : DelegatingHandler { + /// + /// The used to mint short-lived service JWTs containing full admin-level permissions. + /// private readonly JwtTokenService _tokenService; + /// + /// Logger for diagnostic messages about outgoing authenticated requests. + /// private readonly ILogger _logger; /// @@ -16,14 +22,16 @@ public sealed class AuthForwardingHandler : DelegatingHandler { /// public AuthForwardingHandler( JwtTokenService tokenService, - ILogger logger ) { + ILogger logger + ) { _tokenService = tokenService; _logger = logger; } /// protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken ) { + HttpRequestMessage request, CancellationToken cancellationToken + ) { string token = _tokenService.GenerateServiceToken( ); request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", token ); diff --git a/src/Werkr.Server/Identity/IdentitySeeder.cs b/src/Werkr.Server/Identity/IdentitySeeder.cs index eb5e311..21ab943 100644 --- a/src/Werkr.Server/Identity/IdentitySeeder.cs +++ b/src/Werkr.Server/Identity/IdentitySeeder.cs @@ -1,8 +1,6 @@ using System.Security.Cryptography; - using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; - using Werkr.Common.Auth; using Werkr.Data.Identity; using Werkr.Data.Identity.Entities; @@ -62,7 +60,8 @@ public static async Task SeedAsync( IServiceProvider services ) { // Seed default admin if no admin exists IList admins = await userManager.GetUsersInRoleAsync( - DefaultRoles.Admin.ToString( ) ); + DefaultRoles.Admin.ToString() + ); if (admins.Count == 0) { string generatedPassword = GenerateDefaultAdminPassword( ); @@ -111,16 +110,21 @@ public static async Task SeedAsync( IServiceProvider services ) { .CreateLogger( "Werkr.Identity.Seeder" ); foreach (IdentityError error in result.Errors) { logger.LogError( "Failed to create default admin: {Code} — {Description}", - error.Code, error.Description ); + error.Code, error.Description + ); } } } } + /// + /// Ensures that every defined in for each entry has a corresponding row in the database. Missing mappings are inserted; existing ones are left untouched. + /// private static async Task SeedPermissionsAsync( RoleManager roleManager, - WerkrIdentityDbContext dbContext ) { + WerkrIdentityDbContext dbContext + ) { foreach ((DefaultRoles defaultRole, Permission[] permissions) in s_defaultPermissions) { IdentityRole? role = await roleManager.FindByNameAsync( defaultRole.ToString( ) ); if (role is null) { @@ -143,6 +147,9 @@ private static async Task SeedPermissionsAsync( _ = await dbContext.SaveChangesAsync( ); } + /// + /// Generates a cryptographically random 24-character password that satisfies typical ASP.NET Core Identity password-complexity rules. The password is guaranteed to contain at least one uppercase letter, one lowercase letter, one digit, and one symbol before the remaining characters are filled from the full character set and Fisher-Yates shuffled. + /// private static string GenerateDefaultAdminPassword( ) { const string Upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const string Lower = "abcdefghijklmnopqrstuvwxyz"; diff --git a/src/Werkr.Server/Identity/JwtTokenService.cs b/src/Werkr.Server/Identity/JwtTokenService.cs index 1dbba42..1a89c58 100644 --- a/src/Werkr.Server/Identity/JwtTokenService.cs +++ b/src/Werkr.Server/Identity/JwtTokenService.cs @@ -2,7 +2,6 @@ using System.Text; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; - using Werkr.Common.Auth; using Werkr.Data.Identity.Entities; @@ -17,10 +16,25 @@ namespace Werkr.Server.Identity; /// /// public sealed class JwtTokenService { + /// + /// The HMAC-SHA256 symmetric signing key derived from the Jwt:SigningKey configuration value. Must be at least 32 characters (256 bits). + /// private readonly SymmetricSecurityKey _signingKey; + /// + /// The iss (issuer) claim value embedded in every generated token. Defaults to "werkr-api" when not configured. + /// private readonly string _issuer; + /// + /// The aud (audience) claim value embedded in every generated token. Defaults to "werkr" when not configured. + /// private readonly string _audience; + /// + /// The lifetime applied to every minted token. Defaults to 15 minutes when the Jwt:TokenLifetimeMinutes configuration key is absent or unparseable. + /// private readonly TimeSpan _tokenLifetime; + /// + /// Logger for recording debug-level details about generated tokens. + /// private readonly ILogger _logger; /// @@ -79,7 +93,8 @@ public string GenerateToken( ApiKey apiKey, IReadOnlyList permission if (_logger.IsEnabled( LogLevel.Debug )) { _logger.LogDebug( "Generated JWT for API key '{Name}' (prefix: {Prefix}), expires in {Lifetime} minutes, permissions: {Permissions}.", apiKey.Name, apiKey.KeyPrefix, _tokenLifetime.TotalMinutes, - string.Join( ", ", permissions ) ); + string.Join( ", ", permissions ) + ); } return tokenString; @@ -88,7 +103,7 @@ public string GenerateToken( ApiKey apiKey, IReadOnlyList permission /// /// Mints a JWT for Server→API service-to-service calls. /// Uses a built-in service identity with full permissions. - /// No API key required — the Server is the token issuer and trusts itself. + /// No API key required - the Server is the token issuer and trusts itself. /// /// The signed JWT token string. public string GenerateServiceToken( ) { diff --git a/src/Werkr.Server/Identity/WerkrCookieAuthEvents.cs b/src/Werkr.Server/Identity/WerkrCookieAuthEvents.cs index be4dc43..e52f2ed 100644 --- a/src/Werkr.Server/Identity/WerkrCookieAuthEvents.cs +++ b/src/Werkr.Server/Identity/WerkrCookieAuthEvents.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Identity; - using Werkr.Data.Identity.Entities; namespace Werkr.Server.Identity; @@ -45,6 +44,9 @@ public override async Task ValidatePrincipal( CookieValidatePrincipalContext con } } + /// + /// Determines whether the specified is allowed while the user is being forced to change their password. Allowed paths include the change-password page, the Blazor SignalR hub (/_blazor), logout, access-denied, and static assets. + /// private static bool IsAllowedPathForPasswordChange( PathString path ) { return path.StartsWithSegments( "/account/change-password", StringComparison.OrdinalIgnoreCase ) || path.StartsWithSegments( "/_blazor", StringComparison.OrdinalIgnoreCase ) @@ -53,6 +55,9 @@ private static bool IsAllowedPathForPasswordChange( PathString path ) { || IsStaticAsset( path ); } + /// + /// Determines whether the specified is allowed while the user is being required to enrol in multi-factor authentication. In addition to the MFA enrolment page itself, the change-password page, Blazor hub, logout, access-denied, and static assets are permitted. + /// private static bool IsAllowedPathForMfaEnrollment( PathString path ) { return path.StartsWithSegments( "/account/manage/mfa", StringComparison.OrdinalIgnoreCase ) || path.StartsWithSegments( "/_blazor", StringComparison.OrdinalIgnoreCase ) @@ -62,6 +67,9 @@ private static bool IsAllowedPathForMfaEnrollment( PathString path ) { || IsStaticAsset( path ); } + /// + /// Checks whether the current request targets a static asset path that should always be accessible regardless of password-change or MFA-enrolment gates. Paths under /_framework, /_content, /lib, /css, /js, /images, and any path with a file extension are treated as static assets. + /// private static bool IsStaticAsset( PathString path ) { return path.StartsWithSegments( "/_framework", StringComparison.OrdinalIgnoreCase ) || path.StartsWithSegments( "/_content", StringComparison.OrdinalIgnoreCase ) diff --git a/src/Werkr.Server/Program.cs b/src/Werkr.Server/Program.cs index 10dbe61..bb2851f 100644 --- a/src/Werkr.Server/Program.cs +++ b/src/Werkr.Server/Program.cs @@ -31,7 +31,8 @@ public static async Task Main( string[] args ) { string version = System.Reflection.CustomAttributeExtensions .GetCustomAttribute( - System.Reflection.Assembly.GetEntryAssembly( )! ) + System.Reflection.Assembly.GetEntryAssembly()! + ) ?.InformationalVersion ?? "unknown"; Log.Information( "Werkr Server version {Version}", version ); @@ -43,7 +44,8 @@ public static async Task Main( string[] args ) { ConfigurationReaderOptions readerOptions = new( typeof( Serilog.ConsoleLoggerConfigurationExtensions ).Assembly, typeof( Serilog.FileLoggerConfigurationExtensions ).Assembly, - typeof( Serilog.Sinks.OpenTelemetry.OtlpProtocol ).Assembly ); + typeof(Serilog.Sinks.OpenTelemetry.OtlpProtocol).Assembly + ); _ = builder.Host.UseSerilog( ( ctx, lc ) => lc .ReadFrom.Configuration( ctx.Configuration, readerOptions ) ); @@ -59,7 +61,8 @@ public static async Task Main( string[] args ) { // Identity (uses separate identity database) — provider is configurable via Database:Provider string connectionString = builder.Configuration.GetConnectionString( "werkridentitydb" ) ?? string.Empty; DatabaseProvider dbProvider = Enum.TryParse( - builder.Configuration["Database:Provider"], ignoreCase: true, out DatabaseProvider parsed ) + builder.Configuration["Database:Provider"], ignoreCase: true, out DatabaseProvider parsed + ) ? parsed : DatabaseProvider.Postgres; _ = builder.Services.AddWerkrIdentity( dbProvider, connectionString ); diff --git a/src/Werkr.Server/Services/ActionFormDescriptor.cs b/src/Werkr.Server/Services/ActionFormDescriptor.cs new file mode 100644 index 0000000..ec81b3d --- /dev/null +++ b/src/Werkr.Server/Services/ActionFormDescriptor.cs @@ -0,0 +1,13 @@ +namespace Werkr.Server.Services; + +/// Describes a single built-in action and its expected parameter fields. +/// Action key string (e.g. "CopyFile") stored in ActionSubType. +/// Human-friendly name shown in the dropdown. +/// Short description of what the action does. +/// Ordered list of form fields. +public sealed record ActionFormDescriptor( + string Key, + string DisplayName, + string Description, + IReadOnlyList Fields +); diff --git a/src/Werkr.Server/Services/ActionParameterRegistry.cs b/src/Werkr.Server/Services/ActionParameterRegistry.cs index 00ca8c5..b245622 100644 --- a/src/Werkr.Server/Services/ActionParameterRegistry.cs +++ b/src/Werkr.Server/Services/ActionParameterRegistry.cs @@ -1,141 +1,101 @@ - -using System.Collections.Frozen; - -namespace Werkr.Server.Services; -/// Describes the field type rendered in the parameter editor. -public enum FieldType { - /// Single-line text input. - Text, - /// Multi-line text area. - TextArea, - /// Numeric input. - Number, - /// Boolean toggle / checkbox. - Bool, - /// Dropdown select from a fixed set of options. - Select -} - -/// Describes a single form field for an action parameter. -/// JSON property name (PascalCase) written into the parameters blob. -/// Human-friendly label shown in the form. -/// Control type to render. -/// Whether the field must have a value. -/// Default value as a string (bool → "false", int → "0", etc.). -/// Optional placeholder text. -/// For — the allowed option values. -/// Tooltip or small help text shown below the control. -public sealed record FieldDescriptor( - string Name, - string Label, - FieldType Type, - bool Required = false, - string? DefaultValue = null, - string? Placeholder = null, - string[]? Options = null, - string? HelpText = null ); - -/// Describes a single built-in action and its expected parameter fields. -/// Action key string (e.g. "CopyFile") stored in ActionSubType. -/// Human-friendly name shown in the dropdown. -/// Short description of what the action does. -/// Ordered list of form fields. -public sealed record ActionFormDescriptor( - string Key, - string DisplayName, - string Description, - IReadOnlyList Fields ); - -/// -/// Registry of all built-in action form descriptors. -/// Used by the ActionParameterEditor component to render dynamic forms. -/// -public static class ActionParameterRegistry { - /// Encoding values matching the PowerShell Out-File parameter set. - public static readonly string[] Encodings = [ - "ascii", "utf-8", "utf-8-bom", "utf-16", "utf-16BE", - "utf-32", "utf-32BE", "utf-7", "oem" - ]; - - /// PathType values for TestExists (matches Werkr.Common.Models.PathType enum). - private static readonly string[] PathTypes = ["File", "Directory", "Any"]; - - private static readonly ActionFormDescriptor[] AllDescriptors = [ - // ── File operations ────────────────────────────────────────── - new( "CopyFile", "Copy File", "Copy a file or directory to a new location.", [ - new( "Source", "Source Path", FieldType.Text, Required: true, Placeholder: "C:\\source\\file.txt", HelpText: "Supports wildcard patterns." ), - new( "Destination", "Destination Path", FieldType.Text, Required: true, Placeholder: "C:\\dest\\file.txt" ), - new( "Overwrite", "Overwrite", FieldType.Bool, DefaultValue: "false" ), - new( "Recursive", "Recursive", FieldType.Bool, DefaultValue: "false", HelpText: "Copy directories recursively." ), - ] ), - - new( "MoveFile", "Move File", "Move a file or directory to a new location.", [ - new( "Source", "Source Path", FieldType.Text, Required: true, Placeholder: "C:\\source\\file.txt", HelpText: "Supports wildcard patterns." ), - new( "Destination", "Destination Path", FieldType.Text, Required: true, Placeholder: "C:\\dest\\file.txt" ), - new( "Overwrite", "Overwrite", FieldType.Bool, DefaultValue: "false" ), - ] ), - - new( "RenameFile", "Rename File", "Rename a file or directory.", [ - new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\item" ), - new( "NewName", "New Name", FieldType.Text, Required: true, Placeholder: "new-name.txt", HelpText: "Just the name, not a full path." ), - new( "Overwrite", "Overwrite", FieldType.Bool, DefaultValue: "false" ), - ] ), - - new( "DeleteFile", "Delete File", "Delete a file or directory.", [ - new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\item" ), - new( "Recursive", "Recursive", FieldType.Bool, DefaultValue: "false" ), - new( "Force", "Force", FieldType.Bool, DefaultValue: "false", HelpText: "Remove read-only attributes before deletion." ), - ] ), - - new( "CreateFile", "Create File", "Create a new file with optional content.", [ - new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\file.txt" ), - new( "Content", "Content", FieldType.TextArea, Placeholder: "File content…" ), - new( "Overwrite", "Overwrite", FieldType.Bool, DefaultValue: "false" ), - new( "Encoding", "Encoding", FieldType.Select, DefaultValue: "utf-8", Options: Encodings ), - new( "CreateParentDirectories", "Create Parent Dirs", FieldType.Bool, DefaultValue: "true" ), - ] ), - - new( "CreateDirectory", "Create Directory", "Create one or more directories.", [ - new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\directory" ), - ] ), - - new( "TestExists", "Test Exists", "Check if a path exists.", [ - new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\check" ), - new( "Type", "Path Type", FieldType.Select, DefaultValue: "Any", Options: PathTypes, HelpText: "File, Directory, or Any." ), - ] ), - - // ── Content operations ─────────────────────────────────────── - new( "ClearContent", "Clear Content", "Truncate a file to zero bytes.", [ - new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\file.txt" ), - ] ), - - new( "WriteContent", "Write Content", "Write or append text to a file.", [ - new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\file.txt" ), - new( "Content", "Content", FieldType.TextArea, Required: true, Placeholder: "Text to write…" ), - new( "Append", "Append", FieldType.Bool, DefaultValue: "false", HelpText: "Append instead of overwriting." ), - new( "Encoding", "Encoding", FieldType.Select, DefaultValue: "utf-8", Options: Encodings ), - ] ), - - // ── Process operations ─────────────────────────────────────── - new( "StartProcess", "Start Process", "Launch a process.", [ - new( "FileName", "File Name", FieldType.Text, Required: true, Placeholder: "notepad.exe" ), - new( "Arguments", "Arguments", FieldType.Text, Placeholder: "--flag value" ), - new( "WorkingDirectory", "Working Directory", FieldType.Text, Placeholder: "C:\\work" ), - new( "WaitForExit", "Wait for Exit", FieldType.Bool, DefaultValue: "false" ), - new( "TimeoutMs", "Timeout (ms)", FieldType.Number, HelpText: "Only used when Wait for Exit is true." ), - ] ), - - new( "StopProcess", "Stop Process", "Stop a running process.", [ - new( "ProcessName", "Process Name", FieldType.Text, Required: true, Placeholder: "notepad" ), - new( "ProcessId", "Process ID", FieldType.Number, HelpText: "Optional — when set only this PID is stopped." ), - new( "Force", "Force", FieldType.Bool, DefaultValue: "false", HelpText: "Forcefully terminate the process." ), - ] ), - ]; - - /// Fast lookup by action key (case-insensitive). - public static readonly FrozenDictionary Actions = - AllDescriptors.ToFrozenDictionary( d => d.Key, StringComparer.OrdinalIgnoreCase ); - - /// Ordered list of all descriptors for populating dropdowns. - public static IReadOnlyList All => AllDescriptors; -} +using System.Collections.Frozen; +using Werkr.Server.Components.Shared; + +namespace Werkr.Server.Services; + +/// +/// Registry of all built-in action form descriptors. +/// Used by the component to render dynamic forms. +/// +public static class ActionParameterRegistry { + /// Encoding values matching the PowerShell Out-File parameter set. + public static readonly string[] Encodings = [ + "ascii", "utf-8", "utf-8-bom", "utf-16", "utf-16BE", + "utf-32", "utf-32BE", "utf-7", "oem" + ]; + + /// PathType values for TestExists (matches Werkr.Common.Models.PathType enum). + private static readonly string[] s_pathTypes = ["File", "Directory", "Any"]; + + /// + /// The master array of all instances that define every supported action and its parameters. This array is the source of truth from which and are derived. + /// + private static readonly ActionFormDescriptor[] s_allDescriptors = [ + // ── File operations ────────────────────────────────────────── + new( "CopyFile", "Copy File", "Copy a file or directory to a new location.", [ + new( "Source", "Source Path", FieldType.Text, Required: true, Placeholder: "C:\\source\\file.txt", HelpText: "Supports wildcard patterns." ), + new( "Destination", "Destination Path", FieldType.Text, Required: true, Placeholder: "C:\\dest\\file.txt" ), + new( "Overwrite", "Overwrite", FieldType.Bool, DefaultValue: "false" ), + new( "Recursive", "Recursive", FieldType.Bool, DefaultValue: "false", HelpText: "Copy directories recursively." ), + ] ), + + new( "MoveFile", "Move File", "Move a file or directory to a new location.", [ + new( "Source", "Source Path", FieldType.Text, Required: true, Placeholder: "C:\\source\\file.txt", HelpText: "Supports wildcard patterns." ), + new( "Destination", "Destination Path", FieldType.Text, Required: true, Placeholder: "C:\\dest\\file.txt" ), + new( "Overwrite", "Overwrite", FieldType.Bool, DefaultValue: "false" ), + ] ), + + new( "RenameFile", "Rename File", "Rename a file or directory.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\item" ), + new( "NewName", "New Name", FieldType.Text, Required: true, Placeholder: "new-name.txt", HelpText: "Just the name, not a full path." ), + new( "Overwrite", "Overwrite", FieldType.Bool, DefaultValue: "false" ), + ] ), + + new( "DeleteFile", "Delete File", "Delete a file or directory.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\item" ), + new( "Recursive", "Recursive", FieldType.Bool, DefaultValue: "false" ), + new( "Force", "Force", FieldType.Bool, DefaultValue: "false", HelpText: "Remove read-only attributes before deletion." ), + ] ), + + new( "CreateFile", "Create File", "Create a new file with optional content.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\file.txt" ), + new( "Content", "Content", FieldType.TextArea, Placeholder: "File content…" ), + new( "Overwrite", "Overwrite", FieldType.Bool, DefaultValue: "false" ), + new( "Encoding", "Encoding", FieldType.Select, DefaultValue: "utf-8", Options: Encodings ), + new( "CreateParentDirectories", "Create Parent Dirs", FieldType.Bool, DefaultValue: "true" ), + ] ), + + new( "CreateDirectory", "Create Directory", "Create one or more directories.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\directory" ), + ] ), + + new( "TestExists", "Test Exists", "Check if a path exists.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\check" ), + new( "Type", "Path Type", FieldType.Select, DefaultValue: "Any", Options: s_pathTypes, HelpText: "File, Directory, or Any." ), + ] ), + + // ── Content operations ─────────────────────────────────────── + new( "ClearContent", "Clear Content", "Truncate a file to zero bytes.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\file.txt" ), + ] ), + + new( "WriteContent", "Write Content", "Write or append text to a file.", [ + new( "Path", "Path", FieldType.Text, Required: true, Placeholder: "C:\\path\\to\\file.txt" ), + new( "Content", "Content", FieldType.TextArea, Required: true, Placeholder: "Text to write…" ), + new( "Append", "Append", FieldType.Bool, DefaultValue: "false", HelpText: "Append instead of overwriting." ), + new( "Encoding", "Encoding", FieldType.Select, DefaultValue: "utf-8", Options: Encodings ), + ] ), + + // ── Process operations ─────────────────────────────────────── + new( "StartProcess", "Start Process", "Launch a process.", [ + new( "FileName", "File Name", FieldType.Text, Required: true, Placeholder: "notepad.exe" ), + new( "Arguments", "Arguments", FieldType.Text, Placeholder: "--flag value" ), + new( "WorkingDirectory", "Working Directory", FieldType.Text, Placeholder: "C:\\work" ), + new( "WaitForExit", "Wait for Exit", FieldType.Bool, DefaultValue: "false" ), + new( "TimeoutMs", "Timeout (ms)", FieldType.Number, HelpText: "Only used when Wait for Exit is true." ), + ] ), + + new( "StopProcess", "Stop Process", "Stop a running process.", [ + new( "ProcessName", "Process Name", FieldType.Text, Required: true, Placeholder: "notepad" ), + new( "ProcessId", "Process ID", FieldType.Number, HelpText: "Optional — when set only this PID is stopped." ), + new( "Force", "Force", FieldType.Bool, DefaultValue: "false", HelpText: "Forcefully terminate the process." ), + ] ), + ]; + + /// Fast lookup by action key (case-insensitive). + public static readonly FrozenDictionary Actions = + s_allDescriptors.ToFrozenDictionary( d => d.Key, StringComparer.OrdinalIgnoreCase ); + + /// Ordered list of all descriptors for populating dropdowns. + public static IReadOnlyList All => s_allDescriptors; +} diff --git a/src/Werkr.Server/Services/AgentHealthMonitorService.cs b/src/Werkr.Server/Services/AgentHealthMonitorService.cs index e8a3e95..4c3659f 100644 --- a/src/Werkr.Server/Services/AgentHealthMonitorService.cs +++ b/src/Werkr.Server/Services/AgentHealthMonitorService.cs @@ -9,15 +9,25 @@ namespace Werkr.Server.Services; /// with actual reachability so all pages (not just the Dashboard) see accurate data. /// public sealed class AgentHealthMonitorService : BackgroundService { + /// + /// Factory used to create instances of the "ApiService" named HTTP client which has the in its pipeline. + /// private readonly IHttpClientFactory _httpClientFactory; + /// + /// Cached server configuration from which the polling interval is read. + /// private readonly ServerConfigCache _configCache; + /// + /// Logger for informational, warning, and debug messages. + /// private readonly ILogger _logger; /// Initializes the health monitor. public AgentHealthMonitorService( IHttpClientFactory httpClientFactory, ServerConfigCache configCache, - ILogger logger ) { + ILogger logger + ) { _httpClientFactory = httpClientFactory; _configCache = configCache; _logger = logger; @@ -44,12 +54,16 @@ protected override async Task ExecuteAsync( CancellationToken stoppingToken ) { } } + /// + /// Fetches the latest agent health data from /api/agents/health on the Werkr.Api and PUTs an updated status for each agent whose health result indicates a changed connection state. Unrecognised status strings are silently skipped. + /// private async Task PollAndUpdateAsync( CancellationToken ct ) { HttpClient client = _httpClientFactory.CreateClient( "ApiService" ); // Get live health from the API (which does real gRPC checks) List? healthResults = await client.GetFromJsonAsync>( - "/api/agents/health", ct ); + "/api/agents/health", ct + ); if (healthResults is null || healthResults.Count == 0) { return; @@ -73,7 +87,8 @@ private async Task PollAndUpdateAsync( CancellationToken ct ) { using HttpResponseMessage response = await client.PutAsJsonAsync( $"/api/agents/{health.AgentId}/status", new UpdateAgentStatusRequest( newStatus ), - ct ); + ct + ); if (!response.IsSuccessStatusCode) { _logger.LogDebug( "Failed to update agent status." ); diff --git a/src/Werkr.Server/Services/FieldDescriptor.cs b/src/Werkr.Server/Services/FieldDescriptor.cs new file mode 100644 index 0000000..44aee2b --- /dev/null +++ b/src/Werkr.Server/Services/FieldDescriptor.cs @@ -0,0 +1,21 @@ +namespace Werkr.Server.Services; + +/// Describes a single form field for an action parameter. +/// JSON property name (PascalCase) written into the parameters blob. +/// Human-friendly label shown in the form. +/// Control type to render. +/// Whether the field must have a value. +/// Default value as a string (bool → "false", int → "0", etc.). +/// Optional placeholder text. +/// For - the allowed option values. +/// Tooltip or small help text shown below the control. +public sealed record FieldDescriptor( + string Name, + string Label, + FieldType Type, + bool Required = false, + string? DefaultValue = null, + string? Placeholder = null, + string[]? Options = null, + string? HelpText = null +); diff --git a/src/Werkr.Server/Services/FieldType.cs b/src/Werkr.Server/Services/FieldType.cs new file mode 100644 index 0000000..7107e12 --- /dev/null +++ b/src/Werkr.Server/Services/FieldType.cs @@ -0,0 +1,15 @@ +namespace Werkr.Server.Services; + +/// Describes the field type rendered in the parameter editor. +public enum FieldType { + /// Single-line text input. + Text, + /// Multi-line text area. + TextArea, + /// Numeric input. + Number, + /// Boolean toggle / checkbox. + Bool, + /// Dropdown select from a fixed set of options. + Select +} diff --git a/src/Werkr.Server/Services/ServerConfigCache.cs b/src/Werkr.Server/Services/ServerConfigCache.cs index f13f36a..bdab570 100644 --- a/src/Werkr.Server/Services/ServerConfigCache.cs +++ b/src/Werkr.Server/Services/ServerConfigCache.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; - using Werkr.Data.Identity; using Werkr.Data.Identity.Entities; @@ -14,8 +13,17 @@ namespace Werkr.Server.Services; /// /// public sealed class ServerConfigCache { + /// + /// Root service provider used to create scoped services for database access. + /// private readonly IServiceProvider _services; + /// + /// Logger for recording cache initialisation and refresh events. + /// private readonly ILogger _logger; + /// + /// The currently cached configuration entity. Marked because it may be replaced by a background refresh while other threads read it. + /// private volatile ConfigurationSettings _config = new( ); /// Creates a new instance backed by the application service provider. @@ -39,7 +47,6 @@ public ServerConfigCache( IServiceProvider services, ILogger public bool AllowRegistration => _config.AllowRegistration; // ── Lifecycle ──────────────────────────────────────────────────── - /// /// Load config from the database, creating a default row if none exists. /// Called once from Program.cs after DB migration and before the identity seeder. diff --git a/src/Werkr.Server/Utilities/SchedulePreviewCalculator.cs b/src/Werkr.Server/Utilities/SchedulePreviewCalculator.cs index f8a44bd..97cc23e 100644 --- a/src/Werkr.Server/Utilities/SchedulePreviewCalculator.cs +++ b/src/Werkr.Server/Utilities/SchedulePreviewCalculator.cs @@ -3,9 +3,9 @@ namespace Werkr.Server.Utilities; /// -/// Client-side occurrence preview calculator for schedule forms. -/// Computes the next N occurrences from a -/// without requiring a server round-trip. +/// Produces a preview list of upcoming UTC occurrences for a schedule definition. +/// Handles daily, weekly, and monthly recurrence patterns as well as +/// intra-day repeat windows. /// internal static class SchedulePreviewCalculator { /// @@ -67,11 +67,13 @@ internal static List Calculate( ScheduleCreateRequest request, int cou } /// - /// Adds intra-cycle repeat occurrences within the RepeatDurationMinutes window. + /// Appends intra-day repeat occurrences following a base occurrence + /// for up to RepeatDurationMinutes. /// private static void AddRepeatOccurrences( List results, DateTime baseOccurrence, - RepeatOptionsDto? repeatOptions, DateTime windowEnd, int maxCount ) { + RepeatOptionsDto? repeatOptions, DateTime windowEnd, int maxCount + ) { if (repeatOptions is null || repeatOptions.RepeatIntervalMinutes <= 0) { return; } @@ -98,6 +100,10 @@ private static void AddRepeatOccurrences( } } + /// + /// Advances the UTC cursor to the next primary occurrence based on the active + /// recurrence type (daily, weekly, or monthly). + /// private static DateTime AdvanceCursor( DateTime cursorUtc, TimeZoneInfo tz, ScheduleCreateRequest req ) { DateTime local = TimeZoneInfo.ConvertTimeFromUtc( cursorUtc, tz ); @@ -115,6 +121,10 @@ private static DateTime AdvanceCursor( DateTime cursorUtc, TimeZoneInfo tz, Sche return TimeZoneInfo.ConvertTimeToUtc( local, tz ); } + /// + /// Advances the local-time cursor to the next weekly occurrence + /// based on the selected days-of-week bitmask and the configured week interval. + /// private static DateTime AdvanceWeekly( DateTime local, WeeklyRecurrenceDto weekly ) { // Try next day-of-week in current week, otherwise jump to next N-week cycle DateTime next = local.AddDays( 1 ); @@ -141,6 +151,10 @@ private static DateTime AdvanceWeekly( DateTime local, WeeklyRecurrenceDto weekl return local.AddDays( interval * 7 ); } + /// + /// Advances the local-time cursor to the next monthly occurrence, supporting + /// both day-number and week-number + day-of-week modes. + /// private static DateTime AdvanceMonthly( DateTime local, MonthlyRecurrenceDto monthly ) { // Week+Day mode: find Nth weekday of matching month if (monthly.WeekNumber is not null && monthly.DaysOfWeek is not null) { @@ -169,6 +183,9 @@ private static DateTime AdvanceMonthly( DateTime local, MonthlyRecurrenceDto mon return local.AddYears( 2 ); // fallback } + /// + /// Advances the local-time cursor to the next matching month for monthly recurrences that use a week-number + day-of-week combination (e.g. "second Tuesday of the month"). + /// private static DateTime AdvanceMonthlyWeekAndDay( DateTime local, MonthlyRecurrenceDto monthly ) { DateTime next = local.AddMonths( 1 ); for (int attempt = 0; attempt < 24; attempt++) { @@ -191,7 +208,8 @@ private static DateTime AdvanceMonthlyWeekAndDay( DateTime local, MonthlyRecurre /// private static DateTime? FindWeekAndDayInMonth( int year, int month, int hour, int minute, int second, - int weekNumberFlags, int daysOfWeekFlags, DateTimeKind kind ) { + int weekNumberFlags, int daysOfWeekFlags, DateTimeKind kind + ) { int daysInMonth = DateTime.DaysInMonth( year, month ); _ = new DateTime( year, month, 1 ); // Build a list of (weekNumber, dayOfWeek, dayOfMonth) for each day @@ -224,6 +242,9 @@ private static int ResolveDayNumber( int rawDay, int year, int month ) { return 0; // 0 is invalid } + /// + /// Converts a value to a single-bit flag suitable for comparison against the bitmask format used by and (Sunday = 1, Monday = 2, Tuesday = 4, …, Saturday = 64). + /// private static int DayOfWeekToFlag( DayOfWeek day ) => day switch { DayOfWeek.Sunday => 1, DayOfWeek.Monday => 2, @@ -235,6 +256,9 @@ private static int ResolveDayNumber( int rawDay, int year, int month ) { _ => 0 }; + /// + /// Resolves a time-zone identifier string to a instance. Falls back to when the identifier is not found on the current system. + /// private static TimeZoneInfo GetTimeZone( string timeZoneId ) { try { return TimeZoneInfo.FindSystemTimeZoneById( timeZoneId ); diff --git a/src/Werkr.ServiceDefaults/Extensions.cs b/src/Werkr.ServiceDefaults/Extensions.cs index d510ce6..ba5e688 100644 --- a/src/Werkr.ServiceDefaults/Extensions.cs +++ b/src/Werkr.ServiceDefaults/Extensions.cs @@ -10,12 +10,20 @@ namespace Werkr.ServiceDefaults; // Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // This project should be referenced by each service project in your solution. // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults + /// /// Provides shared service default extensions for Aspire-based applications, /// including OpenTelemetry, health checks, service discovery, and endpoint mapping. /// public static class Extensions { + /// + /// The URL path used for the comprehensive health check endpoint. + /// private const string HealthEndpointPath = "/health"; + + /// + /// The URL path used for the liveness-only health check endpoint, which verifies the process is running. + /// private const string AlivenessEndpointPath = "/alive"; /// @@ -24,7 +32,8 @@ public static class Extensions { /// The host application builder type. /// The builder to configure. /// The configured builder. - public static TBuilder AddServiceDefaults( this TBuilder builder ) where TBuilder : IHostApplicationBuilder { + public static TBuilder AddServiceDefaults( this TBuilder builder ) + where TBuilder : IHostApplicationBuilder { _ = builder.ConfigureOpenTelemetry( ); _ = builder.AddDefaultHealthChecks( ); @@ -48,7 +57,8 @@ public static TBuilder AddServiceDefaults( this TBuilder builder ) whe /// The host application builder type. /// The builder to configure. /// The configured builder. - public static TBuilder ConfigureOpenTelemetry( this TBuilder builder ) where TBuilder : IHostApplicationBuilder { + public static TBuilder ConfigureOpenTelemetry( this TBuilder builder ) + where TBuilder : IHostApplicationBuilder { _ = builder.Services.AddOpenTelemetry( ) .WithLogging( ) .WithMetrics( metrics => { @@ -68,7 +78,13 @@ public static TBuilder ConfigureOpenTelemetry( this TBuilder builder ) return builder; } - private static TBuilder AddOpenTelemetryExporters( this TBuilder builder ) where TBuilder : IHostApplicationBuilder { + /// + /// Conditionally adds the OpenTelemetry Protocol (OTLP) exporter + /// when the OTEL_EXPORTER_OTLP_ENDPOINT configuration value + /// is present and non-empty. + /// + private static TBuilder AddOpenTelemetryExporters( this TBuilder builder ) + where TBuilder : IHostApplicationBuilder { bool useOtlpExporter = !string.IsNullOrWhiteSpace( builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] ); if (useOtlpExporter) { @@ -84,10 +100,15 @@ private static TBuilder AddOpenTelemetryExporters( this TBuilder build /// The host application builder type. /// The builder to configure. /// The configured builder. - public static TBuilder AddDefaultHealthChecks( this TBuilder builder ) where TBuilder : IHostApplicationBuilder { + public static TBuilder AddDefaultHealthChecks( this TBuilder builder ) + where TBuilder : IHostApplicationBuilder { _ = builder.Services.AddHealthChecks( ) // Add a default liveness check to ensure app is responsive - .AddCheck( "self", ( ) => HealthCheckResult.Healthy( ), ["live"] ); + .AddCheck( + "self", + ( ) => HealthCheckResult.Healthy( ), + ["live"] + ); return builder; } @@ -98,16 +119,21 @@ public static TBuilder AddDefaultHealthChecks( this TBuilder builder ) /// The web application instance. /// The application for further endpoint mapping. public static WebApplication MapDefaultEndpoints( this WebApplication app ) { - // Adding health checks endpoints to applications in non-development environments has security implications. - // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + // Adding health checks endpoints to applications in non-development + // environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details + // before enabling these endpoints in non-development environments. if (app.Environment.IsDevelopment( )) { // All health checks must pass for app to be considered ready to accept traffic after starting _ = app.MapHealthChecks( HealthEndpointPath ); // Only health checks tagged with the "live" tag must pass for app to be considered alive - _ = app.MapHealthChecks( AlivenessEndpointPath, new HealthCheckOptions { - Predicate = r => r.Tags.Contains( "live" ) - } ); + _ = app.MapHealthChecks( + AlivenessEndpointPath, + new HealthCheckOptions { + Predicate = r => r.Tags.Contains( "live" ) + } + ); } return app; From 991db0c5dc2e92723862f65da7e9de58a05ab4a3 Mon Sep 17 00:00:00 2001 From: Taylor Marvin Date: Wed, 4 Mar 2026 22:47:17 -0800 Subject: [PATCH 03/37] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Operators/Actions/StartProcessHandlerTests.cs | 1 + .../Unit/Communication/CommandDispatcherTests.cs | 2 +- .../Unit/Registration/BundleExpirationServiceTests.cs | 10 +++++++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/StartProcessHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/StartProcessHandlerTests.cs index 0645b62..8ca7439 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/Actions/StartProcessHandlerTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/StartProcessHandlerTests.cs @@ -226,6 +226,7 @@ public async Task StartProcess_CapturesOutput( ) { _channel.Writer, TestContext.CancellationToken ); + Assert.IsTrue( result.Success ); _channel.Writer.Complete( ); List outputs = []; await foreach (OperatorOutput output in _channel.Reader.ReadAllAsync( TestContext.CancellationToken )) { diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/CommandDispatcherTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/CommandDispatcherTests.cs index 8659b89..ae04ed2 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Communication/CommandDispatcherTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/CommandDispatcherTests.cs @@ -224,7 +224,7 @@ private RegisteredConnection SeedServerConnection( ) { private static async Task> ToListAsync( IAsyncEnumerable sequence, CancellationToken cancellationToken - ){ + ) { List outputs = []; await foreach (OperatorOutput output in sequence.WithCancellation( cancellationToken )) { diff --git a/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs index 16ff55d..841e115 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs @@ -67,7 +67,15 @@ public async Task ExecuteAsync_ExpiredPendingBundles_TransitionsToExpired( ) { // Seed an expired pending bundle using (IServiceScope scope = _serviceProvider.CreateScope( )) { WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); - _ = db.RegistrationBundles.Add( new RegistrationBundle { ConnectionName = "Stale", BundleId = EncryptionProvider.GenerateRandomBytes( 16 ), Status = RegistrationStatus.Pending, ExpiresAt = DateTime.UtcNow.AddHours( -1 ), KeySize = 4096, } ); + _ = db.RegistrationBundles.Add( + new RegistrationBundle { + ConnectionName = "Stale", + BundleId = EncryptionProvider.GenerateRandomBytes( 16 ), + Status = RegistrationStatus.Pending, + ExpiresAt = DateTime.UtcNow.AddHours( -1 ), + KeySize = 4096, + } + ); _ = await db.SaveChangesAsync( TestContext.CancellationToken ); } From c4c6c8c2fc9b62026b6c0bbc8a8167b165acba83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:50:37 +0000 Subject: [PATCH 04/37] Initial plan From 156e0da64e3a47b673aa6f85241059e91e6366dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:57:37 +0000 Subject: [PATCH 05/37] refactor: fix formatting issues flagged in PR review Co-authored-by: tsmarvin <57049894+tsmarvin@users.noreply.github.com> --- .../Helpers/AllowPrefixValidator.cs | 1 - .../Services/PwshServiceTests.cs | 15 +- .../Services/SystemShellServiceTests.cs | 15 +- .../Unit/Ranges/IntRangeTests.cs | 11 +- .../Unit/Ranges/RangeOfDaysTests.cs | 8 +- .../Unit/Ranges/RangeOfMonthsTests.cs | 10 +- .../Unit/Ranges/RangeOfWeekNumsTests.cs | 8 +- .../BundleExpirationServiceTests.cs | 20 +- src/Werkr.Agent/packages.lock.json | 2736 ++++++++++------- src/Werkr.Api/packages.lock.json | 1433 +++++---- src/Werkr.AppHost/packages.lock.json | 19 +- src/Werkr.Data.Identity/packages.lock.json | 969 +++--- src/Werkr.Data/packages.lock.json | 1079 +++---- src/Werkr.Server/packages.lock.json | 1378 ++++++--- 14 files changed, 4550 insertions(+), 3152 deletions(-) diff --git a/src/Test/Werkr.Tests.Agent/Helpers/AllowPrefixValidator.cs b/src/Test/Werkr.Tests.Agent/Helpers/AllowPrefixValidator.cs index 628c529..3bf90e6 100644 --- a/src/Test/Werkr.Tests.Agent/Helpers/AllowPrefixValidator.cs +++ b/src/Test/Werkr.Tests.Agent/Helpers/AllowPrefixValidator.cs @@ -28,7 +28,6 @@ public AllowPrefixValidator( params string[] allowedPrefixes ) { /// is outside every allowed prefix. /// public void ValidatePath( string path ) { - if (!IsPathAllowed( path )) { throw new UnauthorizedAccessException( $"Path '{path}' is outside the configured allowlist." ); diff --git a/src/Test/Werkr.Tests.Agent/Services/PwshServiceTests.cs b/src/Test/Werkr.Tests.Agent/Services/PwshServiceTests.cs index e8342fb..bf87da1 100644 --- a/src/Test/Werkr.Tests.Agent/Services/PwshServiceTests.cs +++ b/src/Test/Werkr.Tests.Agent/Services/PwshServiceTests.cs @@ -14,7 +14,10 @@ namespace Werkr.Tests.Agent.Services; /// -/// Unit tests for the gRPC service. Validates that a valid encrypted produces encrypted output, that the service rejects requests when PowerShell is disabled (), and that a missing results in . +/// Unit tests for the gRPC service. Validates that a valid encrypted +/// produces encrypted output, that the service rejects +/// requests when PowerShell is disabled (), and that a missing +/// results in . /// [TestClass] public class PwshServiceTests { @@ -24,7 +27,8 @@ public class PwshServiceTests { public TestContext TestContext { get; set; } = null!; /// - /// Sends a valid encrypted command, verifies the service writes encrypted output, and asserts the decrypted stream contains the expected command output. + /// Sends a valid encrypted command, verifies the service writes encrypted output, and asserts the + /// decrypted stream contains the expected command output. /// [TestMethod] public async Task RunCommand_ValidEncryptedRequest_WritesEncryptedOutput( ) { @@ -55,7 +59,8 @@ public async Task RunCommand_ValidEncryptedRequest_WritesEncryptedOutput( ) { } /// - /// Verifies that invoking when PowerShell is disabled throws an with . + /// Verifies that invoking when PowerShell is disabled throws an + /// with . /// [TestMethod] public async Task RunCommand_WhenPowerShellDisabled_ThrowsUnimplemented( ) { @@ -82,7 +87,9 @@ public async Task RunCommand_WhenPowerShellDisabled_ThrowsUnimplemented( ) { } /// - /// Verifies that calling without a in the user state throws an with . + /// Verifies that calling without a + /// in the user state throws an with + /// . /// [TestMethod] public async Task RunCommand_MissingConnection_ThrowsInternal( ) { diff --git a/src/Test/Werkr.Tests.Agent/Services/SystemShellServiceTests.cs b/src/Test/Werkr.Tests.Agent/Services/SystemShellServiceTests.cs index bede62c..117702c 100644 --- a/src/Test/Werkr.Tests.Agent/Services/SystemShellServiceTests.cs +++ b/src/Test/Werkr.Tests.Agent/Services/SystemShellServiceTests.cs @@ -14,7 +14,10 @@ namespace Werkr.Tests.Agent.Services; /// -/// Unit tests for the gRPC service. Validates that a valid encrypted produces encrypted output, that the service rejects requests when the system shell is disabled (), and that a missing results in . +/// Unit tests for the gRPC service. Validates that a valid encrypted +/// produces encrypted output, that the service rejects +/// requests when the system shell is disabled (), and that a missing +/// results in . /// [TestClass] public class SystemShellServiceTests { @@ -24,7 +27,8 @@ public class SystemShellServiceTests { public TestContext TestContext { get; set; } = null!; /// - /// Sends a valid encrypted command, verifies the service writes encrypted output, and asserts the decrypted stream contains the expected shell output. + /// Sends a valid encrypted command, verifies the service writes encrypted output, and asserts the + /// decrypted stream contains the expected shell output. /// [TestMethod] public async Task RunCommand_ValidEncryptedRequest_WritesEncryptedOutput( ) { @@ -55,7 +59,8 @@ public async Task RunCommand_ValidEncryptedRequest_WritesEncryptedOutput( ) { } /// - /// Verifies that invoking when the system shell is disabled throws an with . + /// Verifies that invoking when the system shell is disabled + /// throws an with . /// [TestMethod] public async Task RunCommand_WhenSystemShellDisabled_ThrowsUnimplemented( ) { @@ -82,7 +87,9 @@ public async Task RunCommand_WhenSystemShellDisabled_ThrowsUnimplemented( ) { } /// - /// Verifies that calling without a in the user state throws an with . + /// Verifies that calling without a + /// in the user state throws an + /// with . /// [TestMethod] public async Task RunCommand_MissingConnection_ThrowsInternal( ) { diff --git a/src/Test/Werkr.Tests.Data/Unit/Ranges/IntRangeTests.cs b/src/Test/Werkr.Tests.Data/Unit/Ranges/IntRangeTests.cs index 6575b5c..f4c8ceb 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Ranges/IntRangeTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Ranges/IntRangeTests.cs @@ -119,16 +119,7 @@ public void ToString_Range_ReturnsDashSeparated( ) { /// [TestMethod] public void ToString_MultipleRanges_ReturnsCommaSeparated( ) { - IntRange[] ranges = [new( - 1, - 3 - ), new( - 10, - 10 - ), new( - 7, - 9 - )]; + IntRange[] ranges = [new( 1, 3 ), new( 10, 10 ), new( 7, 9 )]; string result = IntRange.ToString( ranges ); Assert.AreEqual( "1 - 3, 7 - 9, 10", diff --git a/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfDaysTests.cs b/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfDaysTests.cs index bd239e3..273383e 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfDaysTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfDaysTests.cs @@ -17,7 +17,7 @@ public class RangeOfDaysTests { /// [TestMethod] public void GetContiguousRanges_SingleDay_ReturnsSingleRange( ) { - List ranges = [.. RangeOfDays .GetContiguousRanges( DaysOfWeek.Wednesday )]; + List ranges = [.. RangeOfDays.GetContiguousRanges( DaysOfWeek.Wednesday )]; Assert.HasCount( 1, @@ -40,7 +40,7 @@ public void GetContiguousRanges_SingleDay_ReturnsSingleRange( ) { public void GetContiguousRanges_ContiguousDays_ReturnsSingleRange( ) { DaysOfWeek weekdays = DaysOfWeek.Monday | DaysOfWeek.Tuesday | DaysOfWeek.Wednesday | DaysOfWeek.Thursday | DaysOfWeek.Friday; - List ranges = [.. RangeOfDays .GetContiguousRanges( weekdays )]; + List ranges = [.. RangeOfDays.GetContiguousRanges( weekdays )]; Assert.HasCount( 1, @@ -62,7 +62,7 @@ public void GetContiguousRanges_ContiguousDays_ReturnsSingleRange( ) { [TestMethod] public void GetContiguousRanges_MondayWednesdayFriday_ReturnsThreeRanges( ) { DaysOfWeek days = DaysOfWeek.Monday | DaysOfWeek.Wednesday | DaysOfWeek.Friday; - List ranges = [.. RangeOfDays .GetContiguousRanges( days )]; + List ranges = [.. RangeOfDays.GetContiguousRanges( days )]; Assert.HasCount( 3, @@ -78,7 +78,7 @@ public void GetContiguousRanges_AllDays_ReturnsSingleRange( ) { DaysOfWeek all = DaysOfWeek.Monday | DaysOfWeek.Tuesday | DaysOfWeek.Wednesday | DaysOfWeek.Thursday | DaysOfWeek.Friday | DaysOfWeek.Saturday | DaysOfWeek.Sunday; - List ranges = [.. RangeOfDays .GetContiguousRanges( all )]; + List ranges = [.. RangeOfDays.GetContiguousRanges( all )]; Assert.HasCount( 1, diff --git a/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfMonthsTests.cs b/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfMonthsTests.cs index ff1272b..1b58512 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfMonthsTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfMonthsTests.cs @@ -17,7 +17,7 @@ public class RangeOfMonthsTests { /// [TestMethod] public void GetContiguousRanges_SingleMonth_ReturnsSingleRange( ) { - List ranges = [.. RangeOfMonths .GetContiguousRanges( MonthsOfYear.March )]; + List ranges = [.. RangeOfMonths.GetContiguousRanges( MonthsOfYear.March )]; Assert.HasCount( 1, @@ -39,7 +39,7 @@ public void GetContiguousRanges_SingleMonth_ReturnsSingleRange( ) { [TestMethod] public void GetContiguousRanges_FirstQuarter_ReturnsSingleRange( ) { MonthsOfYear q1 = MonthsOfYear.January | MonthsOfYear.February | MonthsOfYear.March; - List ranges = [.. RangeOfMonths .GetContiguousRanges( q1 )]; + List ranges = [.. RangeOfMonths.GetContiguousRanges( q1 )]; Assert.HasCount( 1, @@ -62,7 +62,7 @@ public void GetContiguousRanges_FirstQuarter_ReturnsSingleRange( ) { public void GetContiguousRanges_Quarterly_ReturnsFourRanges( ) { MonthsOfYear quarterly = MonthsOfYear.January | MonthsOfYear.April | MonthsOfYear.July | MonthsOfYear.October; - List ranges = [.. RangeOfMonths .GetContiguousRanges( quarterly )]; + List ranges = [.. RangeOfMonths.GetContiguousRanges( quarterly )]; Assert.HasCount( 4, @@ -79,7 +79,7 @@ public void GetContiguousRanges_AllMonths_ReturnsSingleRange( ) { | MonthsOfYear.April | MonthsOfYear.May | MonthsOfYear.June | MonthsOfYear.July | MonthsOfYear.August | MonthsOfYear.September | MonthsOfYear.October | MonthsOfYear.November | MonthsOfYear.December; - List ranges = [.. RangeOfMonths .GetContiguousRanges( all )]; + List ranges = [.. RangeOfMonths.GetContiguousRanges( all )]; Assert.HasCount( 1, @@ -101,7 +101,7 @@ public void GetContiguousRanges_AllMonths_ReturnsSingleRange( ) { [TestMethod] public void GetContiguousRanges_JanMaySep_ReturnsThreeRanges( ) { MonthsOfYear months = MonthsOfYear.January | MonthsOfYear.May | MonthsOfYear.September; - List ranges = [.. RangeOfMonths .GetContiguousRanges( months )]; + List ranges = [.. RangeOfMonths.GetContiguousRanges( months )]; Assert.HasCount( 3, diff --git a/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfWeekNumsTests.cs b/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfWeekNumsTests.cs index 047b8b4..2a0e565 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfWeekNumsTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Ranges/RangeOfWeekNumsTests.cs @@ -17,7 +17,7 @@ public class RangeOfWeekNumsTests { /// [TestMethod] public void GetContiguousRanges_FirstOnly_ReturnsSingleRange( ) { - List ranges = [.. RangeOfWeekNums .GetContiguousRanges( WeekNumberWithinMonth.First )]; + List ranges = [.. RangeOfWeekNums.GetContiguousRanges( WeekNumberWithinMonth.First )]; Assert.HasCount( 1, @@ -40,7 +40,7 @@ public void GetContiguousRanges_FirstOnly_ReturnsSingleRange( ) { public void GetContiguousRanges_FirstThroughThird_ReturnsSingleRange( ) { WeekNumberWithinMonth weeks = WeekNumberWithinMonth.First | WeekNumberWithinMonth.Second | WeekNumberWithinMonth.Third; - List ranges = [.. RangeOfWeekNums .GetContiguousRanges( weeks )]; + List ranges = [.. RangeOfWeekNums.GetContiguousRanges( weeks )]; Assert.HasCount( 1, @@ -62,7 +62,7 @@ public void GetContiguousRanges_FirstThroughThird_ReturnsSingleRange( ) { [TestMethod] public void GetContiguousRanges_FirstAndFifth_ReturnsTwoRanges( ) { WeekNumberWithinMonth weeks = WeekNumberWithinMonth.First | WeekNumberWithinMonth.Fifth; - List ranges = [.. RangeOfWeekNums .GetContiguousRanges( weeks )]; + List ranges = [.. RangeOfWeekNums.GetContiguousRanges( weeks )]; Assert.HasCount( 2, @@ -78,7 +78,7 @@ public void GetContiguousRanges_AllSixWeeks_ReturnsSingleRange( ) { WeekNumberWithinMonth all = WeekNumberWithinMonth.First | WeekNumberWithinMonth.Second | WeekNumberWithinMonth.Third | WeekNumberWithinMonth.Fourth | WeekNumberWithinMonth.Fifth | WeekNumberWithinMonth.Sixth; - List ranges = [.. RangeOfWeekNums .GetContiguousRanges( all )]; + List ranges = [.. RangeOfWeekNums.GetContiguousRanges( all )]; Assert.HasCount( 1, diff --git a/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs index 841e115..d465773 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs @@ -124,7 +124,15 @@ public async Task ExecuteAsync_NonPendingBundles_NotModified( ) { // Seed a completed bundle (expired in the past but already completed) using (IServiceScope scope = _serviceProvider.CreateScope( )) { WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); - _ = db.RegistrationBundles.Add( new RegistrationBundle { ConnectionName = "AlreadyDone", BundleId = EncryptionProvider.GenerateRandomBytes( 16 ), Status = RegistrationStatus.Completed, ExpiresAt = DateTime.UtcNow.AddHours( -1 ), KeySize = 4096, } ); + _ = db.RegistrationBundles.Add( + new RegistrationBundle { + ConnectionName = "AlreadyDone", + BundleId = EncryptionProvider.GenerateRandomBytes( 16 ), + Status = RegistrationStatus.Completed, + ExpiresAt = DateTime.UtcNow.AddHours( -1 ), + KeySize = 4096, + } + ); _ = await db.SaveChangesAsync( TestContext.CancellationToken ); } @@ -163,7 +171,15 @@ public async Task ExecuteAsync_UnexpiredBundles_NotModified( ) { // Seed a pending bundle that has not yet expired using (IServiceScope scope = _serviceProvider.CreateScope( )) { WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); - _ = db.RegistrationBundles.Add( new RegistrationBundle { ConnectionName = "Fresh", BundleId = EncryptionProvider.GenerateRandomBytes( 16 ), Status = RegistrationStatus.Pending, ExpiresAt = DateTime.UtcNow.AddHours( 24 ), KeySize = 4096, } ); + _ = db.RegistrationBundles.Add( + new RegistrationBundle { + ConnectionName = "Fresh", + BundleId = EncryptionProvider.GenerateRandomBytes( 16 ), + Status = RegistrationStatus.Pending, + ExpiresAt = DateTime.UtcNow.AddHours( 24 ), + KeySize = 4096, + } + ); _ = await db.SaveChangesAsync( TestContext.CancellationToken ); } diff --git a/src/Werkr.Agent/packages.lock.json b/src/Werkr.Agent/packages.lock.json index fcf4fbc..ea7f4a2 100644 --- a/src/Werkr.Agent/packages.lock.json +++ b/src/Werkr.Agent/packages.lock.json @@ -1,1159 +1,1579 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "Grpc.AspNetCore": { - "type": "Direct", - "requested": "[2.76.0, )", - "resolved": "2.76.0", - "contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==", - "dependencies": { - "Google.Protobuf": "3.31.1", - "Grpc.AspNetCore.Server.ClientFactory": "2.76.0", - "Grpc.Tools": "2.76.0" - } - }, - "Grpc.Tools": { - "type": "Direct", - "requested": "[2.78.0, )", - "resolved": "2.78.0", - "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" - }, - "Microsoft.PowerShell.SDK": { - "type": "Direct", - "requested": "[7.5.4, )", - "resolved": "7.5.4", - "contentHash": "VjRoL4Eja88vOpEflx17ijURIZ3Q5780PTAD8XYhXmlMca6uUghh3qwhpWOQJF8OpYOLUiA6fRPRvoayX2BSXA==", - "dependencies": { - "Microsoft.Bcl.AsyncInterfaces": "8.0.0", - "Microsoft.Management.Infrastructure.CimCmdlets": "7.5.4", - "Microsoft.PowerShell.Commands.Diagnostics": "7.5.4", - "Microsoft.PowerShell.Commands.Management": "7.5.4", - "Microsoft.PowerShell.Commands.Utility": "7.5.4", - "Microsoft.PowerShell.ConsoleHost": "7.5.4", - "Microsoft.PowerShell.Security": "7.5.4", - "Microsoft.WSMan.Management": "7.5.4", - "Microsoft.Win32.Registry.AccessControl": "9.0.10", - "Microsoft.Win32.SystemEvents": "9.0.10", - "Microsoft.Windows.Compatibility": "9.0.10", - "System.CodeDom": "9.0.10", - "System.ComponentModel.Composition": "9.0.10", - "System.ComponentModel.Composition.Registration": "9.0.10", - "System.Configuration.ConfigurationManager": "9.0.10", - "System.Data.Odbc": "9.0.10", - "System.Data.OleDb": "9.0.10", - "System.Data.SqlClient": "4.9.0", - "System.Diagnostics.PerformanceCounter": "9.0.10", - "System.DirectoryServices": "9.0.10", - "System.DirectoryServices.AccountManagement": "9.0.10", - "System.DirectoryServices.Protocols": "9.0.10", - "System.Drawing.Common": "9.0.10", - "System.IO.Packaging": "9.0.10", - "System.IO.Ports": "9.0.10", - "System.Management": "9.0.10", - "System.Management.Automation": "7.5.4", - "System.Net.Http.WinHttpHandler": "9.0.10", - "System.Private.ServiceModel": "4.10.3", - "System.Reflection.Context": "9.0.10", - "System.Runtime.Caching": "9.0.10", - "System.Security.Cryptography.Pkcs": "9.0.10", - "System.Security.Cryptography.ProtectedData": "9.0.10", - "System.Security.Permissions": "9.0.10", - "System.ServiceModel.Duplex": "4.10.3", - "System.ServiceModel.Http": "4.10.3", - "System.ServiceModel.NetTcp": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3", - "System.ServiceModel.Security": "4.10.3", - "System.ServiceProcess.ServiceController": "9.0.10", - "System.Speech": "9.0.10", - "System.Web.Services.Description": "8.0.0", - "System.Windows.Extensions": "9.0.10", - "runtime.android-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-x86.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.native.System.IO.Ports": "9.0.10", - "runtime.osx-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.osx-x64.runtime.native.System.IO.Ports": "9.0.10" - } - }, - "Serilog.AspNetCore": { - "type": "Direct", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", - "dependencies": { - "Serilog": "4.3.0", - "Serilog.Extensions.Hosting": "10.0.0", - "Serilog.Formatting.Compact": "3.0.0", - "Serilog.Settings.Configuration": "10.0.0", - "Serilog.Sinks.Console": "6.1.1", - "Serilog.Sinks.Debug": "3.0.0", - "Serilog.Sinks.File": "7.0.0" - } - }, - "Serilog.Sinks.Console": { - "type": "Direct", - "requested": "[6.1.1, )", - "resolved": "6.1.1", - "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "Serilog.Sinks.File": { - "type": "Direct", - "requested": "[7.0.0, )", - "resolved": "7.0.0", - "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", - "dependencies": { - "Serilog": "4.2.0" - } - }, - "Serilog.Sinks.OpenTelemetry": { - "type": "Direct", - "requested": "[4.2.0, )", - "resolved": "4.2.0", - "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", - "dependencies": { - "Google.Protobuf": "3.30.1", - "Grpc.Net.Client": "2.70.0", - "Serilog": "4.2.0" - } - }, - "Grpc.AspNetCore.Server": { - "type": "Transitive", - "resolved": "2.76.0", - "contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==", - "dependencies": { - "Grpc.Net.Common": "2.76.0" - } - }, - "Grpc.AspNetCore.Server.ClientFactory": { - "type": "Transitive", - "resolved": "2.76.0", - "contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==", - "dependencies": { - "Grpc.AspNetCore.Server": "2.76.0", - "Grpc.Net.ClientFactory": "2.76.0" - } - }, - "Grpc.Core.Api": { - "type": "Transitive", - "resolved": "2.76.0", - "contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ==" - }, - "Grpc.Net.Common": { - "type": "Transitive", - "resolved": "2.76.0", - "contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==", - "dependencies": { - "Grpc.Core.Api": "2.76.0" - } - }, - "Humanizer.Core": { - "type": "Transitive", - "resolved": "2.14.1", - "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" - }, - "Json.More.Net": { - "type": "Transitive", - "resolved": "2.0.2", - "contentHash": "izscdjjk8EAHDBCjyz7V7n77SzkrSjh/hUGV6cyR6PlVdjYDh5ohc8yqvwSqJ9+6Uof8W6B24dIHlDKD+I1F8A==" - }, - "JsonPointer.Net": { - "type": "Transitive", - "resolved": "5.0.2", - "contentHash": "H/OtixKadr+ja1j7Fru3WG56V9zP0AKT1Bd0O7RWN/zH1bl8ZIwW9aCa4+xvzuVvt4SPmrvBu3G6NpAkNOwNAA==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Json.More.Net": "2.0.1.2" - } - }, - "JsonSchema.Net": { - "type": "Transitive", - "resolved": "7.2.3", - "contentHash": "O3KclMcPVFYTZsTeZBpwtKd/lYrNc3AFR+xi9j3Q4CfhDufOUx25TMMWJOcFRrqVklvKQ4Kl+0UhlNX1iDGoRw==", - "dependencies": { - "JsonPointer.Net": "5.0.0" - } - }, - "Markdig.Signed": { - "type": "Transitive", - "resolved": "0.38.0", - "contentHash": "zfi6kNm5QJnsCGm5a0hMG2qw8juYbOfsS4c1OuTcqkbYQUCdkam6d6Nt7nPIrbV4D+U7sHChidSQlg+ViiMPuw==" - }, - "Microsoft.ApplicationInsights": { - "type": "Transitive", - "resolved": "2.22.0", - "contentHash": "3AOM9bZtku7RQwHyMEY3tQMrHIgjcfRDa6YQpd/QG2LDGvMydSlL9Di+8LLMt7J2RDdfJ7/2jdYv6yHcMJAnNw==" - }, - "Microsoft.Bcl.AsyncInterfaces": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" - }, - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Transitive", - "resolved": "3.11.0", - "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" - }, - "Microsoft.CodeAnalysis.Common": { - "type": "Transitive", - "resolved": "4.11.0", - "contentHash": "djf8ujmqYImFgB04UGtcsEhHrzVqzHowS+EEl/Yunc5LdrYrZhGBWUTXoCF0NzYXJxtfuD+UVQarWpvrNc94Qg==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.3.4" - } - }, - "Microsoft.CodeAnalysis.CSharp": { - "type": "Transitive", - "resolved": "4.11.0", - "contentHash": "6XYi2EusI8JT4y2l/F3VVVS+ISoIX9nqHsZRaG6W5aFeJ5BEuBosHfT/ABb73FN0RZ1Z3cj2j7cL28SToJPXOw==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.3.4", - "Microsoft.CodeAnalysis.Common": "[4.11.0]" - } - }, - "Microsoft.EntityFrameworkCore.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" - }, - "Microsoft.EntityFrameworkCore.Analyzers": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" - }, - "Microsoft.EntityFrameworkCore.Relational": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.3" - } - }, - "Microsoft.EntityFrameworkCore.Sqlite.Core": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", - "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.3", - "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.Extensions.AmbientMetadata.Application": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==" - }, - "Microsoft.Extensions.Compliance.Abstractions": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==" - }, - "Microsoft.Extensions.DependencyInjection.AutoActivation": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" - }, - "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==" - }, - "Microsoft.Extensions.Http.Diagnostics": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", - "dependencies": { - "Microsoft.Extensions.Telemetry": "10.3.0" - } - }, - "Microsoft.Extensions.Resilience": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", - "dependencies": { - "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", - "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", - "Polly.Extensions": "8.4.2", - "Polly.RateLimiting": "8.4.2" - } - }, - "Microsoft.Extensions.ServiceDiscovery.Abstractions": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==" - }, - "Microsoft.Extensions.Telemetry": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", - "dependencies": { - "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", - "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", - "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" - } - }, - "Microsoft.Extensions.Telemetry.Abstractions": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", - "dependencies": { - "Microsoft.Extensions.Compliance.Abstractions": "10.3.0" - } - }, - "Microsoft.IdentityModel.Abstractions": { - "type": "Transitive", - "resolved": "8.16.0", - "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" - }, - "Microsoft.IdentityModel.Logging": { - "type": "Transitive", - "resolved": "8.16.0", - "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", - "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.16.0" - } - }, - "Microsoft.Management.Infrastructure": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "cGZi0q5IujCTVYKo9h22Pw+UwfZDV82HXO8HTxMG2HqntPlT3Ls8jY6punLp4YzCypJNpfCAu2kae3TIyuAiJw==", - "dependencies": { - "Microsoft.Management.Infrastructure.Runtime.Unix": "3.0.0", - "Microsoft.Management.Infrastructure.Runtime.Win": "3.0.0" - } - }, - "Microsoft.Management.Infrastructure.CimCmdlets": { - "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "p2nh2bDZGeAsOLd/QwRrZGahPV1Jy1Z0LNA/ZSqpyN8Cp31qh1UOfpmq4rss5P5deuygAN6DTLn96LY5oEDQpg==", - "dependencies": { - "System.Management.Automation": "7.5.4" - } - }, - "Microsoft.Management.Infrastructure.Runtime.Unix": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "QZE3uEDvZ0m7LabQvcmNOYHp7v1QPBVMpB/ild0WEE8zqUVAP5y9rRI5we37ImI1bQmW5pZ+3HNC70POPm0jBQ==" - }, - "Microsoft.Management.Infrastructure.Runtime.Win": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "uwMyWN33+iQ8Wm/n1yoPXgFoiYNd0HzJyoqSVhaQZyJfaQrJR3udgcIHjqa1qbc3lS6kvfuUMN4TrF4U4refCQ==" - }, - "Microsoft.PowerShell.Commands.Diagnostics": { - "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "sRBHmXm2Ivy6pyAI2OX5PJ1DXbmmA1/OusFEXwdWWEjjiZ0prul3POc3GJoiMSn6WF5dJ6xw53MKZrkvu4uCgA==", - "dependencies": { - "System.Management.Automation": "7.5.4" - } - }, - "Microsoft.PowerShell.Commands.Management": { - "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "OhkLYDIf2xeexTWi+3yBRIrGMCpBBDPGAzKAp0wLCj3IE1D2H1Uj4XEE67y69eLFx7jxVwy2Er9hoTt5joECig==", - "dependencies": { - "Microsoft.PowerShell.Security": "7.5.4", - "System.ServiceProcess.ServiceController": "9.0.10" - } - }, - "Microsoft.PowerShell.Commands.Utility": { - "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "lvVh2zHEC2EnBImCRpu9b+5qqngE5o76gVI1NzIfReBXNtsym51XmX/kCrN0INm98CN3GoxTBa7WTcTJC1H3dw==", - "dependencies": { - "Json.More.Net": "2.0.2", - "JsonPointer.Net": "5.0.2", - "JsonSchema.Net": "7.2.3", - "Markdig.Signed": "0.38.0", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.CSharp": "4.11.0", - "Microsoft.PowerShell.MarkdownRender": "7.2.1", - "Microsoft.Win32.SystemEvents": "9.0.10", - "System.Drawing.Common": "9.0.10", - "System.Management.Automation": "7.5.4" - } - }, - "Microsoft.PowerShell.ConsoleHost": { - "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "0U3DkO631KXj5m4jfsKQrUT795ZvZZAzvjNTNdhO4YNukwSSSzJUTszVVE2NXwbkQZHuAoUjPTwicINZJ87OoQ==", - "dependencies": { - "System.Management.Automation": "7.5.4" - } - }, - "Microsoft.PowerShell.CoreCLR.Eventing": { - "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "1xyl5hcWKs5IDFO1ZWXSoVLPN78CJpo6GykVg3F/kNHkldixODi6yz1bbVmyEAMC64AvA3ZKSs/AZaGNoKTI+w==" - }, - "Microsoft.PowerShell.MarkdownRender": { - "type": "Transitive", - "resolved": "7.2.1", - "contentHash": "o5oUwL23R/KnjQPD2Oi49WAG5j4O4VLo1fPRSyM/aq0HuTrY2RnF4B3MCGk13BfcmK51p9kPlHZ1+8a/ZjO4Jg==", - "dependencies": { - "Markdig.Signed": "0.31.0" - } - }, - "Microsoft.PowerShell.Native": { - "type": "Transitive", - "resolved": "7.4.0", - "contentHash": "FlaJ3JBWhqFToYT0ycMb/Xxzoof7oTQbNyI4UikgubC7AMWt5ptBNKjIAMPvOcvEHr+ohaO9GvRWp3tiyS3sKw==" - }, - "Microsoft.PowerShell.Security": { - "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "k/TMcn7ETkq91qhzncGbHthOEzZjGzcq6U6E4exyJRsRqe2MqaRXGrPifiCXDJ6I/dSQOclSpqFSqE/SWVUFdQ==", - "dependencies": { - "System.Management.Automation": "7.5.4" - } - }, - "Microsoft.Security.Extensions": { - "type": "Transitive", - "resolved": "1.4.0", - "contentHash": "MnHXttc0jHbRrGdTJ+yJBbGDoa4OXhtnKXHQw70foMyAooFtPScZX/dN+Nib47nuglc9Gt29Gfb5Zl+1lAuTeA==" - }, - "Microsoft.Win32.Registry.AccessControl": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "ZYHfH0wgTa4usqMMetFYezSjfkQaMat83b/Ykz1q4qSx1h/OiXFb8ZSsn3ZKttHcxe1bn5m/+Zjz9deVT45L8w==" - }, - "Microsoft.Win32.SystemEvents": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "P1CEtsxar/RhfoH3r1vc9ra28LLVYphpcFBxyRIEMM/jP3qh4j9TU4sWH2RUhMZX+GbFxZ+zz1oSP2n9MwjshA==" - }, - "Microsoft.Windows.Compatibility": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "nCkAfadYeJNfJE/RoKGKFlIHVzovN6/DhLm4ebaCBiLWnP6R/fe22n3BWWqlkT2ignu+GTBrkNLs64e8yCCmGw==", - "dependencies": { - "Microsoft.Win32.Registry.AccessControl": "9.0.10", - "Microsoft.Win32.SystemEvents": "9.0.10", - "System.CodeDom": "9.0.10", - "System.ComponentModel.Composition": "9.0.10", - "System.ComponentModel.Composition.Registration": "9.0.10", - "System.Configuration.ConfigurationManager": "9.0.10", - "System.Data.Odbc": "9.0.10", - "System.Data.OleDb": "9.0.10", - "System.Data.SqlClient": "4.9.0", - "System.Diagnostics.PerformanceCounter": "9.0.10", - "System.DirectoryServices": "9.0.10", - "System.DirectoryServices.AccountManagement": "9.0.10", - "System.DirectoryServices.Protocols": "9.0.10", - "System.Drawing.Common": "9.0.10", - "System.IO.Packaging": "9.0.10", - "System.IO.Ports": "9.0.10", - "System.Management": "9.0.10", - "System.Reflection.Context": "9.0.10", - "System.Runtime.Caching": "9.0.10", - "System.Security.Cryptography.Pkcs": "9.0.10", - "System.Security.Cryptography.ProtectedData": "9.0.10", - "System.Security.Permissions": "9.0.10", - "System.ServiceModel.Duplex": "4.10.3", - "System.ServiceModel.Http": "4.10.3", - "System.ServiceModel.NetTcp": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3", - "System.ServiceModel.Security": "4.10.3", - "System.ServiceModel.Syndication": "9.0.10", - "System.ServiceProcess.ServiceController": "9.0.10", - "System.Speech": "9.0.10", - "System.Web.Services.Description": "4.10.3" - } - }, - "Microsoft.WSMan.Management": { - "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "VaLrRXOuIlflS1zonDAbuKdADLojCeSdDy4d4vILa1l2SO3Yaheh3gGP3g4emPCeVDN75ZokigH8Ehe0OeNO1A==", - "dependencies": { - "Microsoft.WSMan.Runtime": "7.5.4", - "System.Management.Automation": "7.5.4", - "System.ServiceProcess.ServiceController": "9.0.10" - } - }, - "Microsoft.WSMan.Runtime": { - "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "Kw5tys1LdJRl/Sn3qT5Os0VJev1o5TGPPfrd7SfxUFiHLcYeiO0IQGFpumZ9SXr4FxPot1125iu3l2a2VEEBZw==" - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.4", - "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" - }, - "Npgsql": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==" - }, - "OpenTelemetry": { - "type": "Transitive", - "resolved": "1.15.0", - "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", - "dependencies": { - "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" - } - }, - "OpenTelemetry.Api": { - "type": "Transitive", - "resolved": "1.15.0", - "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" - }, - "OpenTelemetry.Api.ProviderBuilderExtensions": { - "type": "Transitive", - "resolved": "1.15.0", - "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", - "dependencies": { - "OpenTelemetry.Api": "1.15.0" - } - }, - "Polly.Core": { - "type": "Transitive", - "resolved": "8.4.2", - "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" - }, - "Polly.Extensions": { - "type": "Transitive", - "resolved": "8.4.2", - "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", - "dependencies": { - "Polly.Core": "8.4.2" - } - }, - "Polly.RateLimiting": { - "type": "Transitive", - "resolved": "8.4.2", - "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", - "dependencies": { - "Polly.Core": "8.4.2" - } - }, - "runtime.android-arm.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "KUeHD0wRFCTS9QHantD5Cv/RzDzVY/mQP1Z/eKLtlX5A5SZvsqeomAoayPdh/QmgSzquoHeIDMAMp8VVU+Xzag==" - }, - "runtime.android-arm64.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "b+z8JoBrZ5TMXiXeh0s+s9/uIVx6PmulEuMaN81JLM68aAb4DWHi7t5CL+8bWJhsFhd8VAYoZ9pi5miNTFPeuA==" - }, - "runtime.android-x64.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "K7u+/G2gPoRLNc974p1Tnp44VRLlpQWZrKEQofBTpyJZPgd46ayvXayqT4jyGodG4O6Q6+yY2pYUYlqv1K2l6w==" - }, - "runtime.android-x86.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "TGT4P40ockzrZf/K46A3VAl2dC2PWAS6WhqqtZJbH5G7XgBMX/FoaoY6DtFtz6u7RVl4zhjdG3QWXEv/u/1Hlg==" - }, - "runtime.linux-arm.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "Y2EEaUtO1JolypkFcqgsDxjmOleHa7d9OxBY4Osw5vIdQpOfP0Qj30czQfkN7cZTQH8NxsSr5WawVbk5yFabFg==" - }, - "runtime.linux-arm64.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "nIFQGoz22wdgtmS4Ce+weqGUBh1kpO4XbNEgCU01+7P/+yZAb+gbRSeJUyUmCPhyW0S8FhX1xgJDH/SiJgP05Q==" - }, - "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "jV3esGC4j69yPlRzj50EbJq4syweBm4rOWKYJ3nWCMbVzTW1YQ2o4QhiVjCDOEKEf6q5eVGEaa6fyVXQ/K95Hw==" - }, - "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "UfoiSWuf75mYJPknRXSezDoFYaCp5dWoUjASjg6gQSa7FD2G59Mee6vMEzHFS+x8N+H8oNnL9TCIZUD4/8e/2Q==" - }, - "runtime.linux-musl-arm.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "CWwqjMVVtYiW4I9wk9YuUaSxxkPZKZ/BKj5ppAsIZv4X9u/dyh8+Qbj3Fly61uUXpGxXU4QFQhYuPi5pJTAOBA==" - }, - "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "NxMtoYPV8lreQggsXWsXXRF/djycKieThc4O2kxGB6EgjsiRDuNdnbODV0lWGV6v4mn08uQvuOhmBP5ZYpVdkA==" - }, - "runtime.linux-musl-x64.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "ZNQ/D4lUGxTbfGAZRWP4vp7tCLqvInit03YXAiFXDWh/DnMEosBjrwcu8vbWgSsF01DUbyZQai8lwAStIZWo8w==" - }, - "runtime.linux-x64.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "mOTksL8qwN9EiWz71Dzhc98iQhKmtHlWH5GbhiCJ+ES2ei1HLr2bXSNMrXRd5s0Wfzg6xeQmT5M9umcgtv8Bzg==" - }, - "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "aKoLfCdLoQGhPh2VYBn6sn1kDuwgtXKJ4D2Ql/2WLCGCuXestpxLBS0JhSVBFSp1HrFdazj4aSwpYurtes+1Gg==" - }, - "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "B+boSbUptH2fiKiUXBIu5hlQ3oH+nAdPY9TNmpU10nuoFW/DNz21fCXY3UOIz9oRXp5Ao2b7RlurgpBl0AgoOQ==" - }, - "runtime.native.System.Data.SqlClient.sni": { - "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "A8v6PGmk+UGbfWo5Ixup0lPM4swuSwOiayJExZwKIOjTlFFQIsu3QnDXECosBEyrWSPryxBVrdqtJyhK3BaupQ==", - "dependencies": { - "runtime.win-arm64.runtime.native.System.Data.SqlClient.sni": "4.4.0", - "runtime.win-x64.runtime.native.System.Data.SqlClient.sni": "4.4.0", - "runtime.win-x86.runtime.native.System.Data.SqlClient.sni": "4.4.0" - } - }, - "runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "7AzXN+J8PkTVctfymH+tEaAmj0wNKFPyACqp5cYff0DrHxnsQhv7xtRWxJRrQ0azOAFGR1mhWN4aM1QkbQQ0Rw==", - "dependencies": { - "runtime.android-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-x86.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.osx-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.osx-x64.runtime.native.System.IO.Ports": "9.0.10" - } - }, - "runtime.osx-arm64.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "m+gRRrmCTwP30YiVnFeZg/zRWgzVcOlN28cIPMkK11C9UU60waLknTnRLlQUagIkWaCDifKJCB6wtEeca5QiMA==" - }, - "runtime.osx-x64.runtime.native.System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "89HgF1Oplzjomn0BLeqJxEC17d//zmbs7CMKT12ZvjvFMvpMFO8uQUZ9xRIh91rM0ByfbhSobe2IRezjpeDNlg==" - }, - "runtime.win-arm64.runtime.native.System.Data.SqlClient.sni": { - "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "LbrynESTp3bm5O/+jGL8v0Qg5SJlTV08lpIpFesXjF6uGNMWqFnUQbYBJwZTeua6E/Y7FIM1C54Ey1btLWupdg==" - }, - "runtime.win-x64.runtime.native.System.Data.SqlClient.sni": { - "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "38ugOfkYJqJoX9g6EYRlZB5U2ZJH51UP8ptxZgdpS07FgOEToV+lS11ouNK2PM12Pr6X/PpT5jK82G3DwH/SxQ==" - }, - "runtime.win-x86.runtime.native.System.Data.SqlClient.sni": { - "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "YhEdSQUsTx+C8m8Bw7ar5/VesXvCFMItyZF7G1AUY+OM0VPZUOeAVpJ4Wl6fydBGUYZxojTDR3I6Bj/+BPkJNA==" - }, - "Serilog": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" - }, - "Serilog.Extensions.Hosting": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", - "dependencies": { - "Serilog": "4.3.0", - "Serilog.Extensions.Logging": "10.0.0" - } - }, - "Serilog.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", - "dependencies": { - "Serilog": "4.2.0" - } - }, - "Serilog.Formatting.Compact": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "Serilog.Settings.Configuration": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", - "dependencies": { - "Microsoft.Extensions.DependencyModel": "10.0.0", - "Serilog": "4.3.0" - } - }, - "Serilog.Sinks.Debug": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "SQLitePCLRaw.bundle_e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", - "dependencies": { - "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", - "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" - } - }, - "SQLitePCLRaw.core": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" - }, - "SQLitePCLRaw.lib.e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" - }, - "SQLitePCLRaw.provider.e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", - "dependencies": { - "SQLitePCLRaw.core": "2.1.11" - } - }, - "System.CodeDom": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "00dAIR9Zx+F+AaipjaQmudX3VVpzYvT0bKVD3WcJq6om6pKNrldnp5bSR0VV6IlwDBa1HObGD+sTFaT/I9bBng==" - }, - "System.ComponentModel.Composition": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "tLJKLlc3VsjTLZ4aAwKicKfLKTAzTSSod+T6TWQSjmmA2JMgVvsU5QA2Ka2+Gq2M8poLaxY2dAipFsJen+ZI/g==" - }, - "System.ComponentModel.Composition.Registration": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "H+iSxY02ucdevQa+4jc5disuSgiLom2gUrdATFmVFWc/1De5HBtssVdcar2mxDbtT5IBKiMvwXVHrnl5jmaQtw==", - "dependencies": { - "System.ComponentModel.Composition": "9.0.10", - "System.Reflection.Context": "9.0.10" - } - }, - "System.Configuration.ConfigurationManager": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "5CBhl5dWmckKEtvk8F6GXtmHxNBoqAC8xILxIntNm7AzHiXQ09CXSLhncIJ/cQWaiNYzLjHZCgtMfx9tkCKHdA==", - "dependencies": { - "System.Security.Cryptography.ProtectedData": "9.0.10" - } - }, - "System.Data.Odbc": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "1GjZfLbeSdfHhKUFhk4oU6f3PSF2DOFILTPLHDuC8Pj7UWvwnl8a+H7LDtwEqIJuZ0O2n0rMjydm+Fn67u0G2w==" - }, - "System.Data.OleDb": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "LwiN01NosLlqowmrD1ej1qM1O3GVZeQZzbWrTwYLyeQUGyTVt8yVsTgsRnIJmKny1ENdVcQ9WhKUjzBnh37fsQ==", - "dependencies": { - "System.Configuration.ConfigurationManager": "9.0.10", - "System.Diagnostics.PerformanceCounter": "9.0.10" - } - }, - "System.Data.SqlClient": { - "type": "Transitive", - "resolved": "4.9.0", - "contentHash": "j4KJO+vC62NyUtNHz854njEqXbT8OmAa5jb1nrGfYWBOcggyYUQE0w/snXeaCjdvkSKWuUD+hfvlbN8pTrJTXg==", - "dependencies": { - "runtime.native.System.Data.SqlClient.sni": "4.4.0" - } - }, - "System.Diagnostics.PerformanceCounter": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "35eXaOLXv8ATGDVr946gK0sNEEOwuFzhjFjTQftWh0swhLiyIjAD1pu17tu/SVENpKPZwqJ2e7IIcLpIs0GEzQ==", - "dependencies": { - "System.Configuration.ConfigurationManager": "9.0.10" - } - }, - "System.DirectoryServices": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "dlSYvBLD/XlW2y7hJA+INfcRRtkouFSEcYSVoYmxwfurVdYJ088+PUYf8kgszAp3cThpMPAPVhNHl1lMYrv9kw==" - }, - "System.DirectoryServices.AccountManagement": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "5sNlMrUPhEH8gmosdAz2ZuKA4S4fBdnkpgw5C9IIgyZzy8xg8wPj9aX5oBhoep48tqDVz0++DBWJxJsi4UjT+A==", - "dependencies": { - "System.Configuration.ConfigurationManager": "9.0.10", - "System.DirectoryServices": "9.0.10", - "System.DirectoryServices.Protocols": "9.0.10" - } - }, - "System.DirectoryServices.Protocols": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "nyJa6GTsPxNYt08Ssl9xHXLyDGozVkmsWgmAegUw9+4TBvS8BO1oV69XlkbyF+oJ6qR4+VPy7lgDWUMapvQfUg==" - }, - "System.Drawing.Common": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "FDakPhIcxHnhslLiz4ZQ+ALpHRpCU3zOep9Mcq+4hL23XwQrzmgJNYvf1tH4kJ/V36wO/ZhRr8nOfiz26P3wKg==", - "dependencies": { - "Microsoft.Win32.SystemEvents": "9.0.10" - } - }, - "System.IO.Packaging": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "dKlnLbyOKFCLa5rda8yUU6M0HhVLMkB7rf9lEWnXVtHdNlq9A/fJmt7s/OhwbYaUfOO8rxshpQLyPn0Pv1a2lQ==" - }, - "System.IO.Ports": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "jMvwu+NOk/+vlOzTp9vpxIeGq+yRA+3EbkmpLMs37AAy9cI8YlY/ntTHL00w26Tvu6cIkx0/TdjmeHm0l99Nqw==", - "dependencies": { - "runtime.native.System.IO.Ports": "9.0.10" - } - }, - "System.Management": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "kJY2C6MjKSqfRkEnc8gn4Jth81Anrgxxpu0MffjEadfpp0Ll/gdGpYnDhRWZd+iFttkfZC0uCjFmCrZARRqq4w==", - "dependencies": { - "System.CodeDom": "9.0.10" - } - }, - "System.Management.Automation": { - "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "kHvz4Gc2sQ670KNU+CMsCmoxSM+hO+qW9ujyf3MbBuDImuKeHL8oo2gq4kZpuncO/MSeOTstx3pW8YE6jqIZYA==", - "dependencies": { - "Microsoft.ApplicationInsights": "2.22.0", - "Microsoft.Management.Infrastructure": "3.0.0", - "Microsoft.PowerShell.CoreCLR.Eventing": "7.5.4", - "Microsoft.PowerShell.Native": "7.4.0", - "Microsoft.Security.Extensions": "1.4.0", - "Microsoft.Win32.Registry.AccessControl": "9.0.10", - "Newtonsoft.Json": "13.0.4", - "System.CodeDom": "9.0.10", - "System.Configuration.ConfigurationManager": "9.0.10", - "System.DirectoryServices": "9.0.10", - "System.Management": "9.0.10", - "System.Security.Cryptography.Pkcs": "9.0.10", - "System.Security.Cryptography.ProtectedData": "9.0.10", - "System.Security.Permissions": "9.0.10", - "System.Windows.Extensions": "9.0.10" - } - }, - "System.Net.Http.WinHttpHandler": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "D7CvYoTJPp/gDP3CMKxyUXUpfs8pFi4mQs+USHlT3Bqq6b83Lqe7gOn/dVPVZ78d2/cimxcqnpB9N2f1cDllWg==" - }, - "System.Private.ServiceModel": { - "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "BcUV7OERlLqGxDXZuIyIMMmk1PbqBblLRbAoigmzIUx/M8A+8epvyPyXRpbgoucKH7QmfYdQIev04Phx2Co08A==", - "dependencies": { - "Microsoft.Bcl.AsyncInterfaces": "5.0.0" - } - }, - "System.Reflection.Context": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "Dv7cY++FuibtTyQfWR7ZVMjdtblYkRH6po+UiyBsUwNri2T+afSqwpZq4F2zsVGxtsNsZpXbrJCDs4PxvwxMrQ==" - }, - "System.Runtime.Caching": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "WFKbtzR8mfIZWeQlYGtyjMcse3DoNR0zLsNAev2dDYM8pY945EzzLPO84qnVa+BIEDF1woD8+TtboWSh65U2DQ==", - "dependencies": { - "System.Configuration.ConfigurationManager": "9.0.10" - } - }, - "System.Security.Cryptography.Pkcs": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "Pg7QZz80fOJZrtJnAdEAIpeor8q7F1ofwXGYgLNr4dR8Mqf2l7lfeTaodQkRetrj+ClQwVVYoyi6g2eOsmstFw==" - }, - "System.Security.Permissions": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "uqzXSkn2nx9nplIdayurMtbLcQQdOGd7TmIQ+X5P65+QWT2S+1aUZfJuH2f+Blr/4W6wxMkiX9aKzLk7lfMZFQ==", - "dependencies": { - "System.Windows.Extensions": "9.0.10" - } - }, - "System.ServiceModel.Duplex": { - "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "IZ8ZahvTenWML7/jGUXSCm6jHlxpMbcb+Hy+h5p1WP9YVtb+Er7FHRRGizqQMINEdK6HhWpD6rzr5PdxNyusdg==", - "dependencies": { - "System.Private.ServiceModel": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3" - } - }, - "System.ServiceModel.Http": { - "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "hodkn0rPTYmoZ9EIPwcleUrOi1gZBPvU0uFvzmJbyxl1lIpVM5GxTrs/pCETStjOXCiXhBDoZQYajquOEfeW/w==", - "dependencies": { - "System.Private.ServiceModel": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3" - } - }, - "System.ServiceModel.NetTcp": { - "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "tP7GN7ehqSIQEz7yOJEtY8ziTpfavf2IQMPKa7r9KGQ75+uEW6/wSlWez7oKQwGYuAHbcGhpJvdG6WoVMKYgkw==", - "dependencies": { - "System.Private.ServiceModel": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3" - } - }, - "System.ServiceModel.Primitives": { - "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "aNcdry95wIP1J+/HcLQM/f/AA73LnBQDNc2uCoZ+c1//KpVRp8nMZv5ApMwK+eDNVdCK8G0NLInF+xG3mfQL+g==", - "dependencies": { - "System.Private.ServiceModel": "4.10.3" - } - }, - "System.ServiceModel.Security": { - "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "vqelKb7DvP2inb6LDJ5Igi8wpOYdtLXn5luDW5qEaqkV2sYO1pKlVYBpr6g6m5SevzbdZlVNu67dQiD/H6EdGQ==", - "dependencies": { - "System.Private.ServiceModel": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3" - } - }, - "System.ServiceModel.Syndication": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "jWOXgKi51ULlPDi+YIWsZglIYUYC1DixAs2j6xdy8fzhuxvXO82yUEXv4wFziqzoG1FmTAV/uv5psxb+3MqB7w==" - }, - "System.ServiceProcess.ServiceController": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "dmH+qHQ5wMjvEI0M2s6J+vmaU9L9ID2D9DWMFa7FiTfINfo3e3zeL4ljX7Dg5gCnFIULPFip2ej2iIAC3X6MFw==" - }, - "System.Speech": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "rtbgAR0AD2yij7tqh/TJFAvsr1KN+Q8hb8JUcAN7uLh5EAkQ8Z4o7bFTQpcZDPec3/KsBFPHZNQS0nTLHEdmwQ==" - }, - "System.Web.Services.Description": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "6pwntR5vqLOzUPU9GcLVNEASAVf0GFeXoRF4p/SWIiU3073ZbWJ6dJM5cpXgylcbJDjlwPqNx9f5Y4Od0cNfDA==" - }, - "System.Windows.Extensions": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "6I+OzjcTx2gtZotjDQXEhWdkfPVxRvT9r9nFWsgt9Of6GwLt9szpIlxx0z2dP3dprg6K3zRU/5bbig+zoVKpfg==" - }, - "werkr.common": { - "type": "Project", - "dependencies": { - "Google.Protobuf": "[3.34.0, )", - "Microsoft.IdentityModel.Tokens": "[8.16.0, )", - "Werkr.Common.Configuration": "[1.0.0, )" - } - }, - "werkr.common.configuration": { - "type": "Project" - }, - "werkr.core": { - "type": "Project", - "dependencies": { - "Grpc.Net.Client": "[2.76.0, )", - "System.Security.Cryptography.ProtectedData": "[10.0.3, )", - "Werkr.Common": "[1.0.0, )", - "Werkr.Data": "[1.0.0, )" - } - }, - "werkr.data": { - "type": "Project", - "dependencies": { - "EFCore.NamingConventions": "[10.0.1, )", - "Microsoft.EntityFrameworkCore": "[10.0.3, )", - "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", - "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", - "Werkr.Common": "[1.0.0, )" - } - }, - "werkr.servicedefaults": { - "type": "Project", - "dependencies": { - "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", - "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", - "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", - "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" - } - }, - "EFCore.NamingConventions": { - "type": "CentralTransitive", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" - } - }, - "Google.Protobuf": { - "type": "CentralTransitive", - "requested": "[3.34.0, )", - "resolved": "3.34.0", - "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" - }, - "Grpc.Net.Client": { - "type": "CentralTransitive", - "requested": "[2.76.0, )", - "resolved": "2.76.0", - "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", - "dependencies": { - "Grpc.Net.Common": "2.76.0" - } - }, - "Grpc.Net.ClientFactory": { - "type": "CentralTransitive", - "requested": "[2.76.0, )", - "resolved": "2.76.0", - "contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==", - "dependencies": { - "Grpc.Net.Client": "2.76.0" - } - }, - "Microsoft.Data.Sqlite.Core": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", - "dependencies": { - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.EntityFrameworkCore": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3" - } - }, - "Microsoft.EntityFrameworkCore.Sqlite": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.Extensions.Http.Resilience": { - "type": "CentralTransitive", - "requested": "[10.3.0, )", - "resolved": "10.3.0", - "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", - "dependencies": { - "Microsoft.Extensions.Http.Diagnostics": "10.3.0", - "Microsoft.Extensions.Resilience": "10.3.0" - } - }, - "Microsoft.Extensions.ServiceDiscovery": { - "type": "CentralTransitive", - "requested": "[10.3.0, )", - "resolved": "10.3.0", - "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", - "dependencies": { - "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" - } - }, - "Microsoft.IdentityModel.Tokens": { - "type": "CentralTransitive", - "requested": "[8.16.0, )", - "resolved": "8.16.0", - "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", - "dependencies": { - "Microsoft.IdentityModel.Logging": "8.16.0" - } - }, - "Npgsql.EntityFrameworkCore.PostgreSQL": { - "type": "CentralTransitive", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", - "Npgsql": "10.0.0" - } - }, - "OpenTelemetry.Exporter.OpenTelemetryProtocol": { - "type": "CentralTransitive", - "requested": "[1.15.0, )", - "resolved": "1.15.0", - "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", - "dependencies": { - "OpenTelemetry": "1.15.0" - } - }, - "OpenTelemetry.Extensions.Hosting": { - "type": "CentralTransitive", - "requested": "[1.15.0, )", - "resolved": "1.15.0", - "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", - "dependencies": { - "OpenTelemetry": "1.15.0" - } - }, - "System.Security.Cryptography.ProtectedData": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" - } - } - } +{ + "version": 2, + "dependencies": { + "net10.0": { + "Grpc.AspNetCore": { + "type": "Direct", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==", + "dependencies": { + "Google.Protobuf": "3.31.1", + "Grpc.AspNetCore.Server.ClientFactory": "2.76.0", + "Grpc.Tools": "2.76.0" + } + }, + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.78.0, )", + "resolved": "2.78.0", + "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" + }, + "Microsoft.PowerShell.SDK": { + "type": "Direct", + "requested": "[7.5.4, )", + "resolved": "7.5.4", + "contentHash": "VjRoL4Eja88vOpEflx17ijURIZ3Q5780PTAD8XYhXmlMca6uUghh3qwhpWOQJF8OpYOLUiA6fRPRvoayX2BSXA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "Microsoft.Extensions.ObjectPool": "8.0.21", + "Microsoft.Management.Infrastructure.CimCmdlets": "7.5.4", + "Microsoft.PowerShell.Commands.Diagnostics": "7.5.4", + "Microsoft.PowerShell.Commands.Management": "7.5.4", + "Microsoft.PowerShell.Commands.Utility": "7.5.4", + "Microsoft.PowerShell.ConsoleHost": "7.5.4", + "Microsoft.PowerShell.Security": "7.5.4", + "Microsoft.WSMan.Management": "7.5.4", + "Microsoft.Win32.Registry.AccessControl": "9.0.10", + "Microsoft.Win32.SystemEvents": "9.0.10", + "Microsoft.Windows.Compatibility": "9.0.10", + "System.CodeDom": "9.0.10", + "System.ComponentModel.Composition": "9.0.10", + "System.ComponentModel.Composition.Registration": "9.0.10", + "System.Configuration.ConfigurationManager": "9.0.10", + "System.Data.Odbc": "9.0.10", + "System.Data.OleDb": "9.0.10", + "System.Data.SqlClient": "4.9.0", + "System.Diagnostics.EventLog": "9.0.10", + "System.Diagnostics.PerformanceCounter": "9.0.10", + "System.DirectoryServices": "9.0.10", + "System.DirectoryServices.AccountManagement": "9.0.10", + "System.DirectoryServices.Protocols": "9.0.10", + "System.Drawing.Common": "9.0.10", + "System.IO.Packaging": "9.0.10", + "System.IO.Ports": "9.0.10", + "System.Management": "9.0.10", + "System.Management.Automation": "7.5.4", + "System.Net.Http.WinHttpHandler": "9.0.10", + "System.Private.ServiceModel": "4.10.3", + "System.Reflection.Context": "9.0.10", + "System.Runtime.Caching": "9.0.10", + "System.Security.Cryptography.Pkcs": "9.0.10", + "System.Security.Cryptography.ProtectedData": "9.0.10", + "System.Security.Cryptography.Xml": "9.0.10", + "System.Security.Permissions": "9.0.10", + "System.ServiceModel.Duplex": "4.10.3", + "System.ServiceModel.Http": "4.10.3", + "System.ServiceModel.NetTcp": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3", + "System.ServiceModel.Security": "4.10.3", + "System.ServiceProcess.ServiceController": "9.0.10", + "System.Speech": "9.0.10", + "System.Text.Encoding.CodePages": "9.0.10", + "System.Text.Encodings.Web": "9.0.10", + "System.Threading.AccessControl": "9.0.10", + "System.Web.Services.Description": "8.0.0", + "System.Windows.Extensions": "9.0.10", + "runtime.android-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-x86.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.native.System.IO.Ports": "9.0.10", + "runtime.osx-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.osx-x64.runtime.native.System.IO.Ports": "9.0.10" + } + }, + "Serilog.AspNetCore": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "Direct", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Sinks.OpenTelemetry": { + "type": "Direct", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", + "dependencies": { + "Google.Protobuf": "3.30.1", + "Grpc.Net.Client": "2.70.0", + "Serilog": "4.2.0" + } + }, + "Grpc.AspNetCore.Server": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0" + } + }, + "Grpc.AspNetCore.Server.ClientFactory": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==", + "dependencies": { + "Grpc.AspNetCore.Server": "2.76.0", + "Grpc.Net.ClientFactory": "2.76.0" + } + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==", + "dependencies": { + "Grpc.Core.Api": "2.76.0" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Json.More.Net": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "izscdjjk8EAHDBCjyz7V7n77SzkrSjh/hUGV6cyR6PlVdjYDh5ohc8yqvwSqJ9+6Uof8W6B24dIHlDKD+I1F8A==" + }, + "JsonPointer.Net": { + "type": "Transitive", + "resolved": "5.0.2", + "contentHash": "H/OtixKadr+ja1j7Fru3WG56V9zP0AKT1Bd0O7RWN/zH1bl8ZIwW9aCa4+xvzuVvt4SPmrvBu3G6NpAkNOwNAA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Json.More.Net": "2.0.1.2" + } + }, + "JsonSchema.Net": { + "type": "Transitive", + "resolved": "7.2.3", + "contentHash": "O3KclMcPVFYTZsTeZBpwtKd/lYrNc3AFR+xi9j3Q4CfhDufOUx25TMMWJOcFRrqVklvKQ4Kl+0UhlNX1iDGoRw==", + "dependencies": { + "JsonPointer.Net": "5.0.0" + } + }, + "Markdig.Signed": { + "type": "Transitive", + "resolved": "0.38.0", + "contentHash": "zfi6kNm5QJnsCGm5a0hMG2qw8juYbOfsS4c1OuTcqkbYQUCdkam6d6Nt7nPIrbV4D+U7sHChidSQlg+ViiMPuw==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.22.0", + "contentHash": "3AOM9bZtku7RQwHyMEY3tQMrHIgjcfRDa6YQpd/QG2LDGvMydSlL9Di+8LLMt7J2RDdfJ7/2jdYv6yHcMJAnNw==", + "dependencies": { + "System.Diagnostics.DiagnosticSource": "5.0.0" + } + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.11.0", + "contentHash": "djf8ujmqYImFgB04UGtcsEhHrzVqzHowS+EEl/Yunc5LdrYrZhGBWUTXoCF0NzYXJxtfuD+UVQarWpvrNc94Qg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.4", + "System.Collections.Immutable": "8.0.0", + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.11.0", + "contentHash": "6XYi2EusI8JT4y2l/F3VVVS+ISoIX9nqHsZRaG6W5aFeJ5BEuBosHfT/ABb73FN0RZ1Z3cj2j7cL28SToJPXOw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.4", + "Microsoft.CodeAnalysis.Common": "[4.11.0]", + "System.Collections.Immutable": "8.0.0", + "System.Reflection.Metadata": "8.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Features": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "djFt1Jt+2uREWWVQiiA4ilYBDtHHY7nK08c5K8xBD9+XFNw3KDVprylrMkH08bZGK3ZHRAkS7JDV9srfLrcm/g==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "M5gWob3dtzlF14oto1lR1ZuSJrR0gGc+obv7zY9LGmX5y3Ndpve29MrrjqJW/m4CFud4TE/KFUuHjjtwxhCO8g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.Telemetry": "10.3.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PBlaoYeusaxNYyN4WFjzcXWlUDSvLUPxy/e6oP1SONOOYA/oBWT2uBmFGJMV9VTtXiXXxCB39LqlYWbsWE4UKA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "dQKlVXzqflsv5X8iDlAN5YmTL1GcLCrOLKo1s9PNdfjqxeu0S/jmWTfiLGno+8+o1qFL3+VFAH5/ftmypN+sPw==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.ServiceDiscovery.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Features": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.Management.Infrastructure": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "cGZi0q5IujCTVYKo9h22Pw+UwfZDV82HXO8HTxMG2HqntPlT3Ls8jY6punLp4YzCypJNpfCAu2kae3TIyuAiJw==", + "dependencies": { + "Microsoft.Management.Infrastructure.Runtime.Unix": "3.0.0", + "Microsoft.Management.Infrastructure.Runtime.Win": "3.0.0" + } + }, + "Microsoft.Management.Infrastructure.CimCmdlets": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "p2nh2bDZGeAsOLd/QwRrZGahPV1Jy1Z0LNA/ZSqpyN8Cp31qh1UOfpmq4rss5P5deuygAN6DTLn96LY5oEDQpg==", + "dependencies": { + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.Management.Infrastructure.Runtime.Unix": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "QZE3uEDvZ0m7LabQvcmNOYHp7v1QPBVMpB/ild0WEE8zqUVAP5y9rRI5we37ImI1bQmW5pZ+3HNC70POPm0jBQ==" + }, + "Microsoft.Management.Infrastructure.Runtime.Win": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "uwMyWN33+iQ8Wm/n1yoPXgFoiYNd0HzJyoqSVhaQZyJfaQrJR3udgcIHjqa1qbc3lS6kvfuUMN4TrF4U4refCQ==" + }, + "Microsoft.PowerShell.Commands.Diagnostics": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "sRBHmXm2Ivy6pyAI2OX5PJ1DXbmmA1/OusFEXwdWWEjjiZ0prul3POc3GJoiMSn6WF5dJ6xw53MKZrkvu4uCgA==", + "dependencies": { + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.PowerShell.Commands.Management": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "OhkLYDIf2xeexTWi+3yBRIrGMCpBBDPGAzKAp0wLCj3IE1D2H1Uj4XEE67y69eLFx7jxVwy2Er9hoTt5joECig==", + "dependencies": { + "Microsoft.PowerShell.Security": "7.5.4", + "System.Diagnostics.EventLog": "9.0.10", + "System.ServiceProcess.ServiceController": "9.0.10" + } + }, + "Microsoft.PowerShell.Commands.Utility": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "lvVh2zHEC2EnBImCRpu9b+5qqngE5o76gVI1NzIfReBXNtsym51XmX/kCrN0INm98CN3GoxTBa7WTcTJC1H3dw==", + "dependencies": { + "Json.More.Net": "2.0.2", + "JsonPointer.Net": "5.0.2", + "JsonSchema.Net": "7.2.3", + "Markdig.Signed": "0.38.0", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "4.11.0", + "Microsoft.PowerShell.MarkdownRender": "7.2.1", + "Microsoft.Win32.SystemEvents": "9.0.10", + "System.Drawing.Common": "9.0.10", + "System.Management.Automation": "7.5.4", + "System.Reflection.Metadata": "8.0.1", + "System.Threading.AccessControl": "9.0.10" + } + }, + "Microsoft.PowerShell.ConsoleHost": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "0U3DkO631KXj5m4jfsKQrUT795ZvZZAzvjNTNdhO4YNukwSSSzJUTszVVE2NXwbkQZHuAoUjPTwicINZJ87OoQ==", + "dependencies": { + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.PowerShell.CoreCLR.Eventing": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "1xyl5hcWKs5IDFO1ZWXSoVLPN78CJpo6GykVg3F/kNHkldixODi6yz1bbVmyEAMC64AvA3ZKSs/AZaGNoKTI+w==", + "dependencies": { + "System.Diagnostics.EventLog": "9.0.10" + } + }, + "Microsoft.PowerShell.MarkdownRender": { + "type": "Transitive", + "resolved": "7.2.1", + "contentHash": "o5oUwL23R/KnjQPD2Oi49WAG5j4O4VLo1fPRSyM/aq0HuTrY2RnF4B3MCGk13BfcmK51p9kPlHZ1+8a/ZjO4Jg==", + "dependencies": { + "Markdig.Signed": "0.31.0" + } + }, + "Microsoft.PowerShell.Native": { + "type": "Transitive", + "resolved": "7.4.0", + "contentHash": "FlaJ3JBWhqFToYT0ycMb/Xxzoof7oTQbNyI4UikgubC7AMWt5ptBNKjIAMPvOcvEHr+ohaO9GvRWp3tiyS3sKw==" + }, + "Microsoft.PowerShell.Security": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "k/TMcn7ETkq91qhzncGbHthOEzZjGzcq6U6E4exyJRsRqe2MqaRXGrPifiCXDJ6I/dSQOclSpqFSqE/SWVUFdQ==", + "dependencies": { + "System.Management.Automation": "7.5.4" + } + }, + "Microsoft.Security.Extensions": { + "type": "Transitive", + "resolved": "1.4.0", + "contentHash": "MnHXttc0jHbRrGdTJ+yJBbGDoa4OXhtnKXHQw70foMyAooFtPScZX/dN+Nib47nuglc9Gt29Gfb5Zl+1lAuTeA==" + }, + "Microsoft.Win32.Registry.AccessControl": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "ZYHfH0wgTa4usqMMetFYezSjfkQaMat83b/Ykz1q4qSx1h/OiXFb8ZSsn3ZKttHcxe1bn5m/+Zjz9deVT45L8w==" + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "P1CEtsxar/RhfoH3r1vc9ra28LLVYphpcFBxyRIEMM/jP3qh4j9TU4sWH2RUhMZX+GbFxZ+zz1oSP2n9MwjshA==" + }, + "Microsoft.Windows.Compatibility": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "nCkAfadYeJNfJE/RoKGKFlIHVzovN6/DhLm4ebaCBiLWnP6R/fe22n3BWWqlkT2ignu+GTBrkNLs64e8yCCmGw==", + "dependencies": { + "Microsoft.Win32.Registry.AccessControl": "9.0.10", + "Microsoft.Win32.SystemEvents": "9.0.10", + "System.CodeDom": "9.0.10", + "System.ComponentModel.Composition": "9.0.10", + "System.ComponentModel.Composition.Registration": "9.0.10", + "System.Configuration.ConfigurationManager": "9.0.10", + "System.Data.Odbc": "9.0.10", + "System.Data.OleDb": "9.0.10", + "System.Data.SqlClient": "4.9.0", + "System.Diagnostics.EventLog": "9.0.10", + "System.Diagnostics.PerformanceCounter": "9.0.10", + "System.DirectoryServices": "9.0.10", + "System.DirectoryServices.AccountManagement": "9.0.10", + "System.DirectoryServices.Protocols": "9.0.10", + "System.Drawing.Common": "9.0.10", + "System.IO.Packaging": "9.0.10", + "System.IO.Ports": "9.0.10", + "System.Management": "9.0.10", + "System.Reflection.Context": "9.0.10", + "System.Runtime.Caching": "9.0.10", + "System.Security.Cryptography.Pkcs": "9.0.10", + "System.Security.Cryptography.ProtectedData": "9.0.10", + "System.Security.Cryptography.Xml": "9.0.10", + "System.Security.Permissions": "9.0.10", + "System.Security.Principal.Windows": "5.0.0", + "System.ServiceModel.Duplex": "4.10.3", + "System.ServiceModel.Http": "4.10.3", + "System.ServiceModel.NetTcp": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3", + "System.ServiceModel.Security": "4.10.3", + "System.ServiceModel.Syndication": "9.0.10", + "System.ServiceProcess.ServiceController": "9.0.10", + "System.Speech": "9.0.10", + "System.Text.Encoding.CodePages": "9.0.10", + "System.Threading.AccessControl": "9.0.10", + "System.Web.Services.Description": "4.10.3" + } + }, + "Microsoft.WSMan.Management": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "VaLrRXOuIlflS1zonDAbuKdADLojCeSdDy4d4vILa1l2SO3Yaheh3gGP3g4emPCeVDN75ZokigH8Ehe0OeNO1A==", + "dependencies": { + "Microsoft.WSMan.Runtime": "7.5.4", + "System.Diagnostics.EventLog": "9.0.10", + "System.Management.Automation": "7.5.4", + "System.ServiceProcess.ServiceController": "9.0.10" + } + }, + "Microsoft.WSMan.Runtime": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "Kw5tys1LdJRl/Sn3qT5Os0VJev1o5TGPPfrd7SfxUFiHLcYeiO0IQGFpumZ9SXr4FxPot1125iu3l2a2VEEBZw==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "OpenTelemetry.Api": "1.15.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, + "runtime.android-arm.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "KUeHD0wRFCTS9QHantD5Cv/RzDzVY/mQP1Z/eKLtlX5A5SZvsqeomAoayPdh/QmgSzquoHeIDMAMp8VVU+Xzag==" + }, + "runtime.android-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "b+z8JoBrZ5TMXiXeh0s+s9/uIVx6PmulEuMaN81JLM68aAb4DWHi7t5CL+8bWJhsFhd8VAYoZ9pi5miNTFPeuA==" + }, + "runtime.android-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "K7u+/G2gPoRLNc974p1Tnp44VRLlpQWZrKEQofBTpyJZPgd46ayvXayqT4jyGodG4O6Q6+yY2pYUYlqv1K2l6w==" + }, + "runtime.android-x86.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "TGT4P40ockzrZf/K46A3VAl2dC2PWAS6WhqqtZJbH5G7XgBMX/FoaoY6DtFtz6u7RVl4zhjdG3QWXEv/u/1Hlg==" + }, + "runtime.linux-arm.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "Y2EEaUtO1JolypkFcqgsDxjmOleHa7d9OxBY4Osw5vIdQpOfP0Qj30czQfkN7cZTQH8NxsSr5WawVbk5yFabFg==" + }, + "runtime.linux-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "nIFQGoz22wdgtmS4Ce+weqGUBh1kpO4XbNEgCU01+7P/+yZAb+gbRSeJUyUmCPhyW0S8FhX1xgJDH/SiJgP05Q==" + }, + "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "jV3esGC4j69yPlRzj50EbJq4syweBm4rOWKYJ3nWCMbVzTW1YQ2o4QhiVjCDOEKEf6q5eVGEaa6fyVXQ/K95Hw==" + }, + "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "UfoiSWuf75mYJPknRXSezDoFYaCp5dWoUjASjg6gQSa7FD2G59Mee6vMEzHFS+x8N+H8oNnL9TCIZUD4/8e/2Q==" + }, + "runtime.linux-musl-arm.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "CWwqjMVVtYiW4I9wk9YuUaSxxkPZKZ/BKj5ppAsIZv4X9u/dyh8+Qbj3Fly61uUXpGxXU4QFQhYuPi5pJTAOBA==" + }, + "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "NxMtoYPV8lreQggsXWsXXRF/djycKieThc4O2kxGB6EgjsiRDuNdnbODV0lWGV6v4mn08uQvuOhmBP5ZYpVdkA==" + }, + "runtime.linux-musl-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "ZNQ/D4lUGxTbfGAZRWP4vp7tCLqvInit03YXAiFXDWh/DnMEosBjrwcu8vbWgSsF01DUbyZQai8lwAStIZWo8w==" + }, + "runtime.linux-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "mOTksL8qwN9EiWz71Dzhc98iQhKmtHlWH5GbhiCJ+ES2ei1HLr2bXSNMrXRd5s0Wfzg6xeQmT5M9umcgtv8Bzg==" + }, + "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "aKoLfCdLoQGhPh2VYBn6sn1kDuwgtXKJ4D2Ql/2WLCGCuXestpxLBS0JhSVBFSp1HrFdazj4aSwpYurtes+1Gg==" + }, + "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "B+boSbUptH2fiKiUXBIu5hlQ3oH+nAdPY9TNmpU10nuoFW/DNz21fCXY3UOIz9oRXp5Ao2b7RlurgpBl0AgoOQ==" + }, + "runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "A8v6PGmk+UGbfWo5Ixup0lPM4swuSwOiayJExZwKIOjTlFFQIsu3QnDXECosBEyrWSPryxBVrdqtJyhK3BaupQ==", + "dependencies": { + "runtime.win-arm64.runtime.native.System.Data.SqlClient.sni": "4.4.0", + "runtime.win-x64.runtime.native.System.Data.SqlClient.sni": "4.4.0", + "runtime.win-x86.runtime.native.System.Data.SqlClient.sni": "4.4.0" + } + }, + "runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "7AzXN+J8PkTVctfymH+tEaAmj0wNKFPyACqp5cYff0DrHxnsQhv7xtRWxJRrQ0azOAFGR1mhWN4aM1QkbQQ0Rw==", + "dependencies": { + "runtime.android-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.android-x86.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.linux-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.osx-arm64.runtime.native.System.IO.Ports": "9.0.10", + "runtime.osx-x64.runtime.native.System.IO.Ports": "9.0.10" + } + }, + "runtime.osx-arm64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "m+gRRrmCTwP30YiVnFeZg/zRWgzVcOlN28cIPMkK11C9UU60waLknTnRLlQUagIkWaCDifKJCB6wtEeca5QiMA==" + }, + "runtime.osx-x64.runtime.native.System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "89HgF1Oplzjomn0BLeqJxEC17d//zmbs7CMKT12ZvjvFMvpMFO8uQUZ9xRIh91rM0ByfbhSobe2IRezjpeDNlg==" + }, + "runtime.win-arm64.runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "LbrynESTp3bm5O/+jGL8v0Qg5SJlTV08lpIpFesXjF6uGNMWqFnUQbYBJwZTeua6E/Y7FIM1C54Ey1btLWupdg==" + }, + "runtime.win-x64.runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "38ugOfkYJqJoX9g6EYRlZB5U2ZJH51UP8ptxZgdpS07FgOEToV+lS11ouNK2PM12Pr6X/PpT5jK82G3DwH/SxQ==" + }, + "runtime.win-x86.runtime.native.System.Data.SqlClient.sni": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "YhEdSQUsTx+C8m8Bw7ar5/VesXvCFMItyZF7G1AUY+OM0VPZUOeAVpJ4Wl6fydBGUYZxojTDR3I6Bj/+BPkJNA==" + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==", + "dependencies": { + "System.Memory": "4.5.3" + } + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "00dAIR9Zx+F+AaipjaQmudX3VVpzYvT0bKVD3WcJq6om6pKNrldnp5bSR0VV6IlwDBa1HObGD+sTFaT/I9bBng==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "System.ComponentModel.Composition": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "tLJKLlc3VsjTLZ4aAwKicKfLKTAzTSSod+T6TWQSjmmA2JMgVvsU5QA2Ka2+Gq2M8poLaxY2dAipFsJen+ZI/g==" + }, + "System.ComponentModel.Composition.Registration": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "H+iSxY02ucdevQa+4jc5disuSgiLom2gUrdATFmVFWc/1De5HBtssVdcar2mxDbtT5IBKiMvwXVHrnl5jmaQtw==", + "dependencies": { + "System.ComponentModel.Composition": "9.0.10", + "System.Reflection.Context": "9.0.10" + } + }, + "System.Configuration.ConfigurationManager": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "5CBhl5dWmckKEtvk8F6GXtmHxNBoqAC8xILxIntNm7AzHiXQ09CXSLhncIJ/cQWaiNYzLjHZCgtMfx9tkCKHdA==", + "dependencies": { + "System.Diagnostics.EventLog": "9.0.10", + "System.Security.Cryptography.ProtectedData": "9.0.10" + } + }, + "System.Data.Odbc": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "1GjZfLbeSdfHhKUFhk4oU6f3PSF2DOFILTPLHDuC8Pj7UWvwnl8a+H7LDtwEqIJuZ0O2n0rMjydm+Fn67u0G2w==" + }, + "System.Data.OleDb": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "LwiN01NosLlqowmrD1ej1qM1O3GVZeQZzbWrTwYLyeQUGyTVt8yVsTgsRnIJmKny1ENdVcQ9WhKUjzBnh37fsQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "9.0.10", + "System.Diagnostics.PerformanceCounter": "9.0.10" + } + }, + "System.Data.SqlClient": { + "type": "Transitive", + "resolved": "4.9.0", + "contentHash": "j4KJO+vC62NyUtNHz854njEqXbT8OmAa5jb1nrGfYWBOcggyYUQE0w/snXeaCjdvkSKWuUD+hfvlbN8pTrJTXg==", + "dependencies": { + "runtime.native.System.Data.SqlClient.sni": "4.4.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "uIpKiKp7EWlYZBK71jYP+maGYjDY9YTi/FxBlZoqDzM1ZHZB7gLqUm4jHvRFwaKfR1/Lrt2rQih9LGPIKyNEow==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "Jc+az1pTMujPLDn2j5eqSfzlO7j/T1K/LB7THxdfRWOxujE4zaitUqBs7sv1t6/xmmvpU6Xx3IofCs4owYH0yQ==" + }, + "System.Diagnostics.PerformanceCounter": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "35eXaOLXv8ATGDVr946gK0sNEEOwuFzhjFjTQftWh0swhLiyIjAD1pu17tu/SVENpKPZwqJ2e7IIcLpIs0GEzQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "9.0.10" + } + }, + "System.DirectoryServices": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "dlSYvBLD/XlW2y7hJA+INfcRRtkouFSEcYSVoYmxwfurVdYJ088+PUYf8kgszAp3cThpMPAPVhNHl1lMYrv9kw==" + }, + "System.DirectoryServices.AccountManagement": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "5sNlMrUPhEH8gmosdAz2ZuKA4S4fBdnkpgw5C9IIgyZzy8xg8wPj9aX5oBhoep48tqDVz0++DBWJxJsi4UjT+A==", + "dependencies": { + "System.Configuration.ConfigurationManager": "9.0.10", + "System.DirectoryServices": "9.0.10", + "System.DirectoryServices.Protocols": "9.0.10" + } + }, + "System.DirectoryServices.Protocols": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "nyJa6GTsPxNYt08Ssl9xHXLyDGozVkmsWgmAegUw9+4TBvS8BO1oV69XlkbyF+oJ6qR4+VPy7lgDWUMapvQfUg==" + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "FDakPhIcxHnhslLiz4ZQ+ALpHRpCU3zOep9Mcq+4hL23XwQrzmgJNYvf1tH4kJ/V36wO/ZhRr8nOfiz26P3wKg==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "9.0.10" + } + }, + "System.IO.Packaging": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "dKlnLbyOKFCLa5rda8yUU6M0HhVLMkB7rf9lEWnXVtHdNlq9A/fJmt7s/OhwbYaUfOO8rxshpQLyPn0Pv1a2lQ==" + }, + "System.IO.Ports": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "jMvwu+NOk/+vlOzTp9vpxIeGq+yRA+3EbkmpLMs37AAy9cI8YlY/ntTHL00w26Tvu6cIkx0/TdjmeHm0l99Nqw==", + "dependencies": { + "runtime.native.System.IO.Ports": "9.0.10" + } + }, + "System.Management": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "kJY2C6MjKSqfRkEnc8gn4Jth81Anrgxxpu0MffjEadfpp0Ll/gdGpYnDhRWZd+iFttkfZC0uCjFmCrZARRqq4w==", + "dependencies": { + "System.CodeDom": "9.0.10" + } + }, + "System.Management.Automation": { + "type": "Transitive", + "resolved": "7.5.4", + "contentHash": "kHvz4Gc2sQ670KNU+CMsCmoxSM+hO+qW9ujyf3MbBuDImuKeHL8oo2gq4kZpuncO/MSeOTstx3pW8YE6jqIZYA==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.22.0", + "Microsoft.Management.Infrastructure": "3.0.0", + "Microsoft.PowerShell.CoreCLR.Eventing": "7.5.4", + "Microsoft.PowerShell.Native": "7.4.0", + "Microsoft.Security.Extensions": "1.4.0", + "Microsoft.Win32.Registry.AccessControl": "9.0.10", + "Newtonsoft.Json": "13.0.4", + "System.CodeDom": "9.0.10", + "System.Configuration.ConfigurationManager": "9.0.10", + "System.Diagnostics.DiagnosticSource": "9.0.10", + "System.Diagnostics.EventLog": "9.0.10", + "System.DirectoryServices": "9.0.10", + "System.Management": "9.0.10", + "System.Security.AccessControl": "6.0.1", + "System.Security.Cryptography.Pkcs": "9.0.10", + "System.Security.Cryptography.ProtectedData": "9.0.10", + "System.Security.Permissions": "9.0.10", + "System.Text.Encoding.CodePages": "9.0.10", + "System.Windows.Extensions": "9.0.10" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA==" + }, + "System.Net.Http.WinHttpHandler": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "D7CvYoTJPp/gDP3CMKxyUXUpfs8pFi4mQs+USHlT3Bqq6b83Lqe7gOn/dVPVZ78d2/cimxcqnpB9N2f1cDllWg==" + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" + }, + "System.Private.ServiceModel": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "BcUV7OERlLqGxDXZuIyIMMmk1PbqBblLRbAoigmzIUx/M8A+8epvyPyXRpbgoucKH7QmfYdQIev04Phx2Co08A==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "5.0.0", + "Microsoft.Extensions.ObjectPool": "5.0.10", + "System.Numerics.Vectors": "4.5.0", + "System.Reflection.DispatchProxy": "4.7.1", + "System.Security.Cryptography.Xml": "6.0.1", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Reflection.Context": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "Dv7cY++FuibtTyQfWR7ZVMjdtblYkRH6po+UiyBsUwNri2T+afSqwpZq4F2zsVGxtsNsZpXbrJCDs4PxvwxMrQ==" + }, + "System.Reflection.DispatchProxy": { + "type": "Transitive", + "resolved": "4.7.1", + "contentHash": "C1sMLwIG6ILQ2bmOT4gh62V6oJlyF4BlHcVMrOoor49p0Ji2tA8QAoqyMcIhAdH6OHKJ8m7BU+r4LK2CUEOKqw==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "+4sz5vGHPlo+5NpAxf2IlABnqVvOHOxv17b4dONv4hVwyNeFAeBevT14DIn7X3YWQ+eQFYO3YeTBNCleAblOKA==" + }, + "System.Runtime.Caching": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "WFKbtzR8mfIZWeQlYGtyjMcse3DoNR0zLsNAev2dDYM8pY945EzzLPO84qnVa+BIEDF1woD8+TtboWSh65U2DQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "9.0.10" + } + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "Pg7QZz80fOJZrtJnAdEAIpeor8q7F1ofwXGYgLNr4dR8Mqf2l7lfeTaodQkRetrj+ClQwVVYoyi6g2eOsmstFw==" + }, + "System.Security.Cryptography.Xml": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "kkEBXInhetgK1+E0NzDSz4S2Yh3wpivGf1A7I88dN4SYINGrQnGciGDJj1RTgsE/zFeJNlAZhXs4XSqn7q8AhQ==", + "dependencies": { + "System.Security.Cryptography.Pkcs": "9.0.10" + } + }, + "System.Security.Permissions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "uqzXSkn2nx9nplIdayurMtbLcQQdOGd7TmIQ+X5P65+QWT2S+1aUZfJuH2f+Blr/4W6wxMkiX9aKzLk7lfMZFQ==", + "dependencies": { + "System.Windows.Extensions": "9.0.10" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + }, + "System.ServiceModel.Duplex": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "IZ8ZahvTenWML7/jGUXSCm6jHlxpMbcb+Hy+h5p1WP9YVtb+Er7FHRRGizqQMINEdK6HhWpD6rzr5PdxNyusdg==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3" + } + }, + "System.ServiceModel.Http": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "hodkn0rPTYmoZ9EIPwcleUrOi1gZBPvU0uFvzmJbyxl1lIpVM5GxTrs/pCETStjOXCiXhBDoZQYajquOEfeW/w==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3" + } + }, + "System.ServiceModel.NetTcp": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "tP7GN7ehqSIQEz7yOJEtY8ziTpfavf2IQMPKa7r9KGQ75+uEW6/wSlWez7oKQwGYuAHbcGhpJvdG6WoVMKYgkw==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3" + } + }, + "System.ServiceModel.Primitives": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "aNcdry95wIP1J+/HcLQM/f/AA73LnBQDNc2uCoZ+c1//KpVRp8nMZv5ApMwK+eDNVdCK8G0NLInF+xG3mfQL+g==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3" + } + }, + "System.ServiceModel.Security": { + "type": "Transitive", + "resolved": "4.10.3", + "contentHash": "vqelKb7DvP2inb6LDJ5Igi8wpOYdtLXn5luDW5qEaqkV2sYO1pKlVYBpr6g6m5SevzbdZlVNu67dQiD/H6EdGQ==", + "dependencies": { + "System.Private.ServiceModel": "4.10.3", + "System.ServiceModel.Primitives": "4.10.3" + } + }, + "System.ServiceModel.Syndication": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "jWOXgKi51ULlPDi+YIWsZglIYUYC1DixAs2j6xdy8fzhuxvXO82yUEXv4wFziqzoG1FmTAV/uv5psxb+3MqB7w==" + }, + "System.ServiceProcess.ServiceController": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "dmH+qHQ5wMjvEI0M2s6J+vmaU9L9ID2D9DWMFa7FiTfINfo3e3zeL4ljX7Dg5gCnFIULPFip2ej2iIAC3X6MFw==", + "dependencies": { + "System.Diagnostics.EventLog": "9.0.10" + } + }, + "System.Speech": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "rtbgAR0AD2yij7tqh/TJFAvsr1KN+Q8hb8JUcAN7uLh5EAkQ8Z4o7bFTQpcZDPec3/KsBFPHZNQS0nTLHEdmwQ==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "UyurlgWm3k3I8LVDmO67GFiu61t4UPzNrTK5fS6K6DADQgIYcFjDnRJWvVls4/R9UjgSL9R+OxJnDXn05fTCCA==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "znmiJFUa0GGwq7t6ShUKBDRlPsNJaudNFI7rVeyGnRBhiRMegBvu2GRcadThP/QX/a5UpGgZbe6tolDooobj/Q==" + }, + "System.Threading.AccessControl": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "eI/pq6KpqMO8TrQPxg6ZRdJvQqB+dw5Uax56UkDE3ZH6x9jQ7VD+ir4JUPD3XtKCUhDjy9FLGWfUAx5Jbz+K1Q==" + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "System.Web.Services.Description": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "6pwntR5vqLOzUPU9GcLVNEASAVf0GFeXoRF4p/SWIiU3073ZbWJ6dJM5cpXgylcbJDjlwPqNx9f5Y4Od0cNfDA==" + }, + "System.Windows.Extensions": { + "type": "Transitive", + "resolved": "9.0.10", + "contentHash": "6I+OzjcTx2gtZotjDQXEhWdkfPVxRvT9r9nFWsgt9Of6GwLt9szpIlxx0z2dP3dprg6K3zRU/5bbig+zoVKpfg==" + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.AspNetCore.Authorization": "[10.0.3, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.core": { + "type": "Project", + "dependencies": { + "Grpc.Net.Client": "[2.76.0, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.3, )", + "System.Security.Cryptography.ProtectedData": "[10.0.3, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )" + } + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "werkr.servicedefaults": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Grpc.Net.ClientFactory": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==", + "dependencies": { + "Grpc.Net.Client": "2.76.0", + "Microsoft.Extensions.Http": "8.0.0" + } + }, + "Microsoft.AspNetCore.Authorization": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.3.0", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Resilience": "10.3.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.ServiceDiscovery": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "OpenTelemetry": "1.15.0" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" + } + } + } } \ No newline at end of file diff --git a/src/Werkr.Api/packages.lock.json b/src/Werkr.Api/packages.lock.json index e6dd24b..6903d4c 100644 --- a/src/Werkr.Api/packages.lock.json +++ b/src/Werkr.Api/packages.lock.json @@ -1,554 +1,881 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "Grpc.AspNetCore": { - "type": "Direct", - "requested": "[2.76.0, )", - "resolved": "2.76.0", - "contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==", - "dependencies": { - "Google.Protobuf": "3.31.1", - "Grpc.AspNetCore.Server.ClientFactory": "2.76.0", - "Grpc.Tools": "2.76.0" - } - }, - "Grpc.Tools": { - "type": "Direct", - "requested": "[2.78.0, )", - "resolved": "2.78.0", - "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" - }, - "Microsoft.AspNetCore.Authentication.JwtBearer": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "TBDs8e9y2vJHp14EwNfnIZUNrm6siw8PAAU5laOrYFuGgRxx8oCdxZyfTgp1Oy/icUk9h/XtpYBHPnXIG0f2/g==", - "dependencies": { - "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1" - } - }, - "Microsoft.AspNetCore.OpenApi": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "SAvSrKDgnY5GDjDAngOXxPhUvEKlTU/0zIq8zidqHvh/xnZBPs0Vc4LqwyvnmnafNnyUaivtRABz4K4wodXfSg==", - "dependencies": { - "Microsoft.OpenApi": "2.0.0" - } - }, - "Microsoft.IdentityModel.JsonWebTokens": { - "type": "Direct", - "requested": "[8.16.0, )", - "resolved": "8.16.0", - "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", - "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.16.0" - } - }, - "Serilog.AspNetCore": { - "type": "Direct", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", - "dependencies": { - "Serilog": "4.3.0", - "Serilog.Extensions.Hosting": "10.0.0", - "Serilog.Formatting.Compact": "3.0.0", - "Serilog.Settings.Configuration": "10.0.0", - "Serilog.Sinks.Console": "6.1.1", - "Serilog.Sinks.Debug": "3.0.0", - "Serilog.Sinks.File": "7.0.0" - } - }, - "Serilog.Sinks.Console": { - "type": "Direct", - "requested": "[6.1.1, )", - "resolved": "6.1.1", - "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "Serilog.Sinks.File": { - "type": "Direct", - "requested": "[7.0.0, )", - "resolved": "7.0.0", - "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", - "dependencies": { - "Serilog": "4.2.0" - } - }, - "Serilog.Sinks.OpenTelemetry": { - "type": "Direct", - "requested": "[4.2.0, )", - "resolved": "4.2.0", - "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", - "dependencies": { - "Google.Protobuf": "3.30.1", - "Grpc.Net.Client": "2.70.0", - "Serilog": "4.2.0" - } - }, - "Grpc.AspNetCore.Server": { - "type": "Transitive", - "resolved": "2.76.0", - "contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==", - "dependencies": { - "Grpc.Net.Common": "2.76.0" - } - }, - "Grpc.AspNetCore.Server.ClientFactory": { - "type": "Transitive", - "resolved": "2.76.0", - "contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==", - "dependencies": { - "Grpc.AspNetCore.Server": "2.76.0", - "Grpc.Net.ClientFactory": "2.76.0" - } - }, - "Grpc.Core.Api": { - "type": "Transitive", - "resolved": "2.76.0", - "contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ==" - }, - "Grpc.Net.Common": { - "type": "Transitive", - "resolved": "2.76.0", - "contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==", - "dependencies": { - "Grpc.Core.Api": "2.76.0" - } - }, - "Microsoft.EntityFrameworkCore.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" - }, - "Microsoft.EntityFrameworkCore.Analyzers": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" - }, - "Microsoft.EntityFrameworkCore.Relational": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.3" - } - }, - "Microsoft.EntityFrameworkCore.Sqlite.Core": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", - "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.3", - "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.Extensions.AmbientMetadata.Application": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==" - }, - "Microsoft.Extensions.Compliance.Abstractions": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==" - }, - "Microsoft.Extensions.DependencyInjection.AutoActivation": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" - }, - "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==" - }, - "Microsoft.Extensions.Http.Diagnostics": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", - "dependencies": { - "Microsoft.Extensions.Telemetry": "10.3.0" - } - }, - "Microsoft.Extensions.Resilience": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", - "dependencies": { - "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", - "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", - "Polly.Extensions": "8.4.2", - "Polly.RateLimiting": "8.4.2" - } - }, - "Microsoft.Extensions.ServiceDiscovery.Abstractions": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==" - }, - "Microsoft.Extensions.Telemetry": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", - "dependencies": { - "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", - "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", - "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" - } - }, - "Microsoft.Extensions.Telemetry.Abstractions": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", - "dependencies": { - "Microsoft.Extensions.Compliance.Abstractions": "10.3.0" - } - }, - "Microsoft.IdentityModel.Abstractions": { - "type": "Transitive", - "resolved": "8.16.0", - "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" - }, - "Microsoft.IdentityModel.Logging": { - "type": "Transitive", - "resolved": "8.16.0", - "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", - "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.16.0" - } - }, - "Microsoft.IdentityModel.Protocols": { - "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==", - "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.0.1" - } - }, - "Microsoft.IdentityModel.Protocols.OpenIdConnect": { - "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==", - "dependencies": { - "Microsoft.IdentityModel.Protocols": "8.0.1", - "System.IdentityModel.Tokens.Jwt": "8.0.1" - } - }, - "Microsoft.OpenApi": { - "type": "Transitive", - "resolved": "2.0.0", - "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" - }, - "Npgsql": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==" - }, - "OpenTelemetry": { - "type": "Transitive", - "resolved": "1.15.0", - "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", - "dependencies": { - "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" - } - }, - "OpenTelemetry.Api": { - "type": "Transitive", - "resolved": "1.15.0", - "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" - }, - "OpenTelemetry.Api.ProviderBuilderExtensions": { - "type": "Transitive", - "resolved": "1.15.0", - "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", - "dependencies": { - "OpenTelemetry.Api": "1.15.0" - } - }, - "Polly.Core": { - "type": "Transitive", - "resolved": "8.4.2", - "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" - }, - "Polly.Extensions": { - "type": "Transitive", - "resolved": "8.4.2", - "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", - "dependencies": { - "Polly.Core": "8.4.2" - } - }, - "Polly.RateLimiting": { - "type": "Transitive", - "resolved": "8.4.2", - "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", - "dependencies": { - "Polly.Core": "8.4.2" - } - }, - "Serilog": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" - }, - "Serilog.Extensions.Hosting": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", - "dependencies": { - "Serilog": "4.3.0", - "Serilog.Extensions.Logging": "10.0.0" - } - }, - "Serilog.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", - "dependencies": { - "Serilog": "4.2.0" - } - }, - "Serilog.Formatting.Compact": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "Serilog.Settings.Configuration": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", - "dependencies": { - "Microsoft.Extensions.DependencyModel": "10.0.0", - "Serilog": "4.3.0" - } - }, - "Serilog.Sinks.Debug": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "SQLitePCLRaw.bundle_e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", - "dependencies": { - "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", - "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" - } - }, - "SQLitePCLRaw.core": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" - }, - "SQLitePCLRaw.lib.e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" - }, - "SQLitePCLRaw.provider.e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", - "dependencies": { - "SQLitePCLRaw.core": "2.1.11" - } - }, - "werkr.common": { - "type": "Project", - "dependencies": { - "Google.Protobuf": "[3.34.0, )", - "Microsoft.IdentityModel.Tokens": "[8.16.0, )", - "Werkr.Common.Configuration": "[1.0.0, )" - } - }, - "werkr.common.configuration": { - "type": "Project" - }, - "werkr.core": { - "type": "Project", - "dependencies": { - "Grpc.Net.Client": "[2.76.0, )", - "System.Security.Cryptography.ProtectedData": "[10.0.3, )", - "Werkr.Common": "[1.0.0, )", - "Werkr.Data": "[1.0.0, )" - } - }, - "werkr.data": { - "type": "Project", - "dependencies": { - "EFCore.NamingConventions": "[10.0.1, )", - "Microsoft.EntityFrameworkCore": "[10.0.3, )", - "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", - "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", - "Werkr.Common": "[1.0.0, )" - } - }, - "werkr.servicedefaults": { - "type": "Project", - "dependencies": { - "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", - "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", - "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", - "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" - } - }, - "EFCore.NamingConventions": { - "type": "CentralTransitive", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" - } - }, - "Google.Protobuf": { - "type": "CentralTransitive", - "requested": "[3.34.0, )", - "resolved": "3.34.0", - "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" - }, - "Grpc.Net.Client": { - "type": "CentralTransitive", - "requested": "[2.76.0, )", - "resolved": "2.76.0", - "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", - "dependencies": { - "Grpc.Net.Common": "2.76.0" - } - }, - "Grpc.Net.ClientFactory": { - "type": "CentralTransitive", - "requested": "[2.76.0, )", - "resolved": "2.76.0", - "contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==", - "dependencies": { - "Grpc.Net.Client": "2.76.0" - } - }, - "Microsoft.Data.Sqlite.Core": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", - "dependencies": { - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.EntityFrameworkCore": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3" - } - }, - "Microsoft.EntityFrameworkCore.Sqlite": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.Extensions.Http.Resilience": { - "type": "CentralTransitive", - "requested": "[10.3.0, )", - "resolved": "10.3.0", - "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", - "dependencies": { - "Microsoft.Extensions.Http.Diagnostics": "10.3.0", - "Microsoft.Extensions.Resilience": "10.3.0" - } - }, - "Microsoft.Extensions.ServiceDiscovery": { - "type": "CentralTransitive", - "requested": "[10.3.0, )", - "resolved": "10.3.0", - "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", - "dependencies": { - "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" - } - }, - "Microsoft.IdentityModel.Tokens": { - "type": "CentralTransitive", - "requested": "[8.16.0, )", - "resolved": "8.16.0", - "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", - "dependencies": { - "Microsoft.IdentityModel.Logging": "8.16.0" - } - }, - "Npgsql.EntityFrameworkCore.PostgreSQL": { - "type": "CentralTransitive", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", - "Npgsql": "10.0.0" - } - }, - "OpenTelemetry.Exporter.OpenTelemetryProtocol": { - "type": "CentralTransitive", - "requested": "[1.15.0, )", - "resolved": "1.15.0", - "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", - "dependencies": { - "OpenTelemetry": "1.15.0" - } - }, - "OpenTelemetry.Extensions.Hosting": { - "type": "CentralTransitive", - "requested": "[1.15.0, )", - "resolved": "1.15.0", - "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", - "dependencies": { - "OpenTelemetry": "1.15.0" - } - }, - "System.IdentityModel.Tokens.Jwt": { - "type": "CentralTransitive", - "requested": "[8.16.0, )", - "resolved": "8.0.1", - "contentHash": "GJw3bYkWpOgvN3tJo5X4lYUeIFA2HD293FPUhKmp7qxS+g5ywAb34Dnd3cDAFLkcMohy5XTpoaZ4uAHuw0uSPQ==", - "dependencies": { - "Microsoft.IdentityModel.JsonWebTokens": "8.0.1", - "Microsoft.IdentityModel.Tokens": "8.0.1" - } - }, - "System.Security.Cryptography.ProtectedData": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" - } - } - } +{ + "version": 2, + "dependencies": { + "net10.0": { + "Grpc.AspNetCore": { + "type": "Direct", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "LyXMmpN2Ba0TE35SOLSKbGqIYtJuhc1UgiaGfoW1X8KJERV70QI5KGW+ckEY7MrXoFWN/uWo4B70siVhbDmCgQ==", + "dependencies": { + "Google.Protobuf": "3.31.1", + "Grpc.AspNetCore.Server.ClientFactory": "2.76.0", + "Grpc.Tools": "2.76.0" + } + }, + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.78.0, )", + "resolved": "2.78.0", + "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" + }, + "Microsoft.AspNetCore.Authentication.JwtBearer": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "TBDs8e9y2vJHp14EwNfnIZUNrm6siw8PAAU5laOrYFuGgRxx8oCdxZyfTgp1Oy/icUk9h/XtpYBHPnXIG0f2/g==", + "dependencies": { + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "SAvSrKDgnY5GDjDAngOXxPhUvEKlTU/0zIq8zidqHvh/xnZBPs0Vc4LqwyvnmnafNnyUaivtRABz4K4wodXfSg==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Direct", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Serilog.AspNetCore": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "Direct", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Sinks.OpenTelemetry": { + "type": "Direct", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", + "dependencies": { + "Google.Protobuf": "3.30.1", + "Grpc.Net.Client": "2.70.0", + "Serilog": "4.2.0" + } + }, + "Grpc.AspNetCore.Server": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "diSC/ZeNdSdxHdYSOpYwuSBBDYpuNVtJQFJfiBB0WrYOQ4lVMmdxuUZJcViahQyo8pCvS3Mueo5lqFxwwMF/iw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0" + } + }, + "Grpc.AspNetCore.Server.ClientFactory": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "y5KGO1GO0N2L/hCCMR05mmoK8j+v8rKvZ+9nothAxKx2Tf2CwV8f4TM5K0GkKfDsp4vrc4lm90MU6E+DeN7YIw==", + "dependencies": { + "Grpc.AspNetCore.Server": "2.76.0", + "Grpc.Net.ClientFactory": "2.76.0" + } + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "cSxC2tdnFdXXuBgIn1pjc4YBx7LXTCp4M0qn+SMBS35VWZY+cEQYLWTBDDhdBH1HzU7BV+ncVZlniGQHMpRJKQ==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.76.0", + "contentHash": "bZpiMVYgvpB44/wBh1RotrkqC7bg2FOasLri2GhR3hMKyzsiTxCoDE49YjPrJeFc4RW0wS8u+EInI09sjxVFRA==", + "dependencies": { + "Grpc.Core.Api": "2.76.0" + } + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Features": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "djFt1Jt+2uREWWVQiiA4ilYBDtHHY7nK08c5K8xBD9+XFNw3KDVprylrMkH08bZGK3ZHRAkS7JDV9srfLrcm/g==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "M5gWob3dtzlF14oto1lR1ZuSJrR0gGc+obv7zY9LGmX5y3Ndpve29MrrjqJW/m4CFud4TE/KFUuHjjtwxhCO8g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.Telemetry": "10.3.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PBlaoYeusaxNYyN4WFjzcXWlUDSvLUPxy/e6oP1SONOOYA/oBWT2uBmFGJMV9VTtXiXXxCB39LqlYWbsWE4UKA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "dQKlVXzqflsv5X8iDlAN5YmTL1GcLCrOLKo1s9PNdfjqxeu0S/jmWTfiLGno+8+o1qFL3+VFAH5/ftmypN+sPw==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.ServiceDiscovery.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Features": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "uA2vpKqU3I2mBBEaeJAWPTjT9v1TZrGWKdgK6G5qJd03CLx83kdiqO9cmiK8/n1erkHzFBwU/RphP83aAe3i3g==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.0.1" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "AQDbfpL+yzuuGhO/mQhKNsp44pm5Jv8/BI4KiFXR7beVGZoSH35zMV3PrmcfvSTsyI6qrcR898NzUauD6SRigg==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.0.1", + "System.IdentityModel.Tokens.Jwt": "8.0.1" + } + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==", + "dependencies": { + "System.Text.Json": "8.0.5" + } + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "OpenTelemetry.Api": "1.15.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==", + "dependencies": { + "System.Memory": "4.5.3" + } + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==" + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.AspNetCore.Authorization": "[10.0.3, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.core": { + "type": "Project", + "dependencies": { + "Grpc.Net.Client": "[2.76.0, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.3, )", + "System.Security.Cryptography.ProtectedData": "[10.0.3, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )" + } + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "werkr.servicedefaults": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", + "dependencies": { + "Grpc.Net.Common": "2.76.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Grpc.Net.ClientFactory": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.76.0", + "contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==", + "dependencies": { + "Grpc.Net.Client": "2.76.0", + "Microsoft.Extensions.Http": "8.0.0" + } + }, + "Microsoft.AspNetCore.Authorization": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.3.0", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Resilience": "10.3.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.ServiceDiscovery": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "OpenTelemetry": "1.15.0" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.0.1", + "contentHash": "GJw3bYkWpOgvN3tJo5X4lYUeIFA2HD293FPUhKmp7qxS+g5ywAb34Dnd3cDAFLkcMohy5XTpoaZ4uAHuw0uSPQ==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.0.1", + "Microsoft.IdentityModel.Tokens": "8.0.1" + } + }, + "System.Security.Cryptography.ProtectedData": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" + } + } + } } \ No newline at end of file diff --git a/src/Werkr.AppHost/packages.lock.json b/src/Werkr.AppHost/packages.lock.json index d4cfb61..b5ead1e 100644 --- a/src/Werkr.AppHost/packages.lock.json +++ b/src/Werkr.AppHost/packages.lock.json @@ -2,11 +2,11 @@ "version": 2, "dependencies": { "net10.0": { - "Aspire.Dashboard.Sdk.osx-arm64": { + "Aspire.Dashboard.Sdk.linux-x64": { "type": "Direct", "requested": "[13.1.2, )", "resolved": "13.1.2", - "contentHash": "AtCqyYuj8Y8Br1XML8YHzQZ1VbiVsxCVas4aK3AbQ3dZExRERcgpCvX4OXZtip2UMMm2ArvnHpx0HX10Z9cbvQ==" + "contentHash": "Y4U1uUGKGtbcEAuM6wXBKQ/Jo1T+57NNUzCTC5l+ZR2ibPjuW7xp4tw/qWdiwjNesN/BcCLmZ4c62jgVrnE9ww==" }, "Aspire.Hosting.AppHost": { "type": "Direct", @@ -41,11 +41,11 @@ "System.IO.Hashing": "9.0.10" } }, - "Aspire.Hosting.Orchestration.osx-arm64": { + "Aspire.Hosting.Orchestration.linux-x64": { "type": "Direct", "requested": "[13.1.2, )", "resolved": "13.1.2", - "contentHash": "3uwx5PZc6u84N9TA15Dv+Z2GdKSl+WDae4ZiwNR8oR881DdZGauZcxJfDwr5zNuupw6jrNc2qXs//D7S7zynkA==" + "contentHash": "Y4L2KiDOd95Pi4zlJ3DlXggq1eMbyAFZcBhNPiWsEsZFwvFkiQtcnz/0+VzOKplcCEE3KhoOIz5yZU1cBZtoEQ==" }, "Aspire.Hosting.PostgreSQL": { "type": "Direct", @@ -498,7 +498,8 @@ "contentHash": "oDKOeKZ865I5X8qmU3IXMyrAnssYEiYWTobPGdrqubN3RtTzEHIv+D6fwhdcfrdhPJzHjCkK/ORztR/IsnmA6g==", "dependencies": { "Microsoft.VisualStudio.Threading.Only": "17.13.61", - "Microsoft.VisualStudio.Validation": "17.8.8" + "Microsoft.VisualStudio.Validation": "17.8.8", + "System.IO.Pipelines": "8.0.0" } }, "Newtonsoft.Json": { @@ -536,7 +537,8 @@ "Microsoft.VisualStudio.Threading.Only": "17.13.61", "Microsoft.VisualStudio.Validation": "17.8.8", "Nerdbank.Streams": "2.12.87", - "Newtonsoft.Json": "13.0.3" + "Newtonsoft.Json": "13.0.3", + "System.IO.Pipelines": "8.0.0" } }, "System.Diagnostics.EventLog": { @@ -549,6 +551,11 @@ "resolved": "9.0.10", "contentHash": "9gv5z71xaWWmcGEs4bXdreIhKp2kYLK2fvPK5gQkgnWMYvZ8ieaxKofDjxL3scZiEYfi/yW2nJTiKV2awcWEdA==" }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "FHNOatmUq0sqJOkTx+UF/9YK1f180cnW5FVqnQMvYUN0elp6wFzbtPSiqbo1/ru8ICp43JM1i7kKkk6GsNGHlA==" + }, "YamlDotNet": { "type": "Transitive", "resolved": "16.3.0", diff --git a/src/Werkr.Data.Identity/packages.lock.json b/src/Werkr.Data.Identity/packages.lock.json index 3c61735..0d35c8e 100644 --- a/src/Werkr.Data.Identity/packages.lock.json +++ b/src/Werkr.Data.Identity/packages.lock.json @@ -1,355 +1,616 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "6SEGWi35DZ9syBqCT8v5vEkm9tWUayWxVkHWLwW2FdyXSwS0zzEpIzGPLVQGeug3VU8d+hK/PFxFwwZnblv/zA==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Relational": "10.0.3" - } - }, - "Microsoft.AspNetCore.Identity.UI": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "xhxrP7QcUuyA2FcZsbvdHSqTauPseNrXzhFUYaRj+Elz1nxJceKbW+COc1P9QbpKeZDh9aTDSldHbz3AnMWOqg==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Embedded": "10.0.3" - } - }, - "Microsoft.EntityFrameworkCore.Design": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "OPZ/u7fONQFmnyUIDB8SeJtKnyFkj1zJsZ0Ke2Cp17q8hYs6jGmYEFd6Ne4Hdcd6auUdFdV7di+uFo2w+L34NA==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.Build.Framework": "18.0.2", - "Microsoft.CodeAnalysis.CSharp": "5.0.0", - "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", - "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", - "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "Mono.TextTemplating": "3.0.0", - "Newtonsoft.Json": "13.0.3" - } - }, - "Humanizer.Core": { - "type": "Transitive", - "resolved": "2.14.1", - "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" - }, - "Microsoft.Build.Framework": { - "type": "Transitive", - "resolved": "18.0.2", - "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" - }, - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Transitive", - "resolved": "3.11.0", - "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" - }, - "Microsoft.CodeAnalysis.Common": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0" - } - }, - "Microsoft.CodeAnalysis.CSharp": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[5.0.0]" - } - }, - "Microsoft.CodeAnalysis.CSharp.Workspaces": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", - "Microsoft.CodeAnalysis.Common": "[5.0.0]", - "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "System.Composition": "9.0.0" - } - }, - "Microsoft.CodeAnalysis.Workspaces.Common": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[5.0.0]", - "System.Composition": "9.0.0" - } - }, - "Microsoft.CodeAnalysis.Workspaces.MSBuild": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.Build.Framework": "17.11.31", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", - "Newtonsoft.Json": "13.0.3", - "System.Composition": "9.0.0" - } - }, - "Microsoft.EntityFrameworkCore.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" - }, - "Microsoft.EntityFrameworkCore.Analyzers": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" - }, - "Microsoft.EntityFrameworkCore.Relational": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.3" - } - }, - "Microsoft.EntityFrameworkCore.Sqlite.Core": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", - "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.3", - "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" - }, - "Microsoft.Extensions.FileProviders.Embedded": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "kw/xPl7m4Gv6bqx2ojihTtWiN2K2AklyMIrvncuSi2MOdwu0oMKoyh0G3p2Brt7m43Q9ER0IaA2G4EGjfgDh/w==" - }, - "Microsoft.IdentityModel.Abstractions": { - "type": "Transitive", - "resolved": "8.16.0", - "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" - }, - "Microsoft.IdentityModel.Logging": { - "type": "Transitive", - "resolved": "8.16.0", - "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", - "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.16.0" - } - }, - "Microsoft.VisualStudio.SolutionPersistence": { - "type": "Transitive", - "resolved": "1.0.52", - "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" - }, - "Mono.TextTemplating": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", - "dependencies": { - "System.CodeDom": "6.0.0" - } - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" - }, - "Npgsql": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==" - }, - "SQLitePCLRaw.bundle_e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", - "dependencies": { - "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", - "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" - } - }, - "SQLitePCLRaw.core": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" - }, - "SQLitePCLRaw.lib.e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" - }, - "SQLitePCLRaw.provider.e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", - "dependencies": { - "SQLitePCLRaw.core": "2.1.11" - } - }, - "System.CodeDom": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" - }, - "System.Composition": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", - "dependencies": { - "System.Composition.AttributedModel": "9.0.0", - "System.Composition.Convention": "9.0.0", - "System.Composition.Hosting": "9.0.0", - "System.Composition.Runtime": "9.0.0", - "System.Composition.TypedParts": "9.0.0" - } - }, - "System.Composition.AttributedModel": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" - }, - "System.Composition.Convention": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", - "dependencies": { - "System.Composition.AttributedModel": "9.0.0" - } - }, - "System.Composition.Hosting": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", - "dependencies": { - "System.Composition.Runtime": "9.0.0" - } - }, - "System.Composition.Runtime": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" - }, - "System.Composition.TypedParts": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", - "dependencies": { - "System.Composition.AttributedModel": "9.0.0", - "System.Composition.Hosting": "9.0.0", - "System.Composition.Runtime": "9.0.0" - } - }, - "werkr.common": { - "type": "Project", - "dependencies": { - "Google.Protobuf": "[3.34.0, )", - "Microsoft.IdentityModel.Tokens": "[8.16.0, )", - "Werkr.Common.Configuration": "[1.0.0, )" - } - }, - "werkr.common.configuration": { - "type": "Project" - }, - "werkr.data": { - "type": "Project", - "dependencies": { - "EFCore.NamingConventions": "[10.0.1, )", - "Microsoft.EntityFrameworkCore": "[10.0.3, )", - "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", - "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", - "Werkr.Common": "[1.0.0, )" - } - }, - "EFCore.NamingConventions": { - "type": "CentralTransitive", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" - } - }, - "Google.Protobuf": { - "type": "CentralTransitive", - "requested": "[3.34.0, )", - "resolved": "3.34.0", - "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" - }, - "Microsoft.Data.Sqlite.Core": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", - "dependencies": { - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.EntityFrameworkCore": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3" - } - }, - "Microsoft.EntityFrameworkCore.Sqlite": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.IdentityModel.Tokens": { - "type": "CentralTransitive", - "requested": "[8.16.0, )", - "resolved": "8.16.0", - "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", - "dependencies": { - "Microsoft.IdentityModel.Logging": "8.16.0" - } - }, - "Npgsql.EntityFrameworkCore.PostgreSQL": { - "type": "CentralTransitive", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", - "Npgsql": "10.0.0" - } - } - } - } +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "6SEGWi35DZ9syBqCT8v5vEkm9tWUayWxVkHWLwW2FdyXSwS0zzEpIzGPLVQGeug3VU8d+hK/PFxFwwZnblv/zA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Identity.Stores": "10.0.3" + } + }, + "Microsoft.AspNetCore.Identity.UI": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xhxrP7QcUuyA2FcZsbvdHSqTauPseNrXzhFUYaRj+Elz1nxJceKbW+COc1P9QbpKeZDh9aTDSldHbz3AnMWOqg==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Embedded": "10.0.3", + "Microsoft.Extensions.Identity.Stores": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "OPZ/u7fONQFmnyUIDB8SeJtKnyFkj1zJsZ0Ke2Cp17q8hYs6jGmYEFd6Ne4Hdcd6auUdFdV7di+uFo2w+L34NA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "STkCfgCECt2cAekgBpXxFDefH5wd4ytYZKihIZSmQqY92BP8N9qN71qFyRpry8Sl/qT5A+bpwe8v7sjDtg5LEA==" + }, + "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c8GgMKpnNf8fUOKXaZXKV5XaLSlvAts8ICvcPr5CIfjHEWJtbq+URIfBGYesyhnOlWAiSgVsdCBZxMEJIHgfLw==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.3" + } + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Embedded": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "kw/xPl7m4Gv6bqx2ojihTtWiN2K2AklyMIrvncuSi2MOdwu0oMKoyh0G3p2Brt7m43Q9ER0IaA2G4EGjfgDh/w==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + }, + "Microsoft.Extensions.Identity.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GdhTmz+BiVEdsFCT7Vqjhlx8q7j7kGPLinJjudPLO48DxZjSIwh9KlOd/AYJoGR21NjkkHiWijcB3RG7rIfMqw==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Identity.Stores": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "XWu+Xg0dc0VKJxW7iTuhpnSD2jqZ4Kcdr7f3vUf7LOmPkawBLGkUuUA3rl+QQCbXAGnomV/I9T2wTxe1BKkVEA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.Identity.Core": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==", + "dependencies": { + "System.Memory": "4.5.3" + } + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA==" + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.AspNetCore.Authorization": "[10.0.3, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Microsoft.AspNetCore.Authorization": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + } + } + } } \ No newline at end of file diff --git a/src/Werkr.Data/packages.lock.json b/src/Werkr.Data/packages.lock.json index 5dfe2cb..2f2033b 100644 --- a/src/Werkr.Data/packages.lock.json +++ b/src/Werkr.Data/packages.lock.json @@ -1,537 +1,544 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "EFCore.NamingConventions": { - "type": "Direct", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" - } - }, - "Microsoft.EntityFrameworkCore": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" - } - }, - "Microsoft.EntityFrameworkCore.Design": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "OPZ/u7fONQFmnyUIDB8SeJtKnyFkj1zJsZ0Ke2Cp17q8hYs6jGmYEFd6Ne4Hdcd6auUdFdV7di+uFo2w+L34NA==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.Build.Framework": "18.0.2", - "Microsoft.CodeAnalysis.CSharp": "5.0.0", - "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", - "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", - "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "Mono.TextTemplating": "3.0.0", - "Newtonsoft.Json": "13.0.3" - } - }, - "Microsoft.EntityFrameworkCore.Sqlite": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Npgsql.EntityFrameworkCore.PostgreSQL": { - "type": "Direct", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", - "Npgsql": "10.0.0" - } - }, - "Humanizer.Core": { - "type": "Transitive", - "resolved": "2.14.1", - "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" - }, - "Microsoft.AspNetCore.Metadata": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" - }, - "Microsoft.Build.Framework": { - "type": "Transitive", - "resolved": "18.0.2", - "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" - }, - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Transitive", - "resolved": "3.11.0", - "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" - }, - "Microsoft.CodeAnalysis.Common": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0" - } - }, - "Microsoft.CodeAnalysis.CSharp": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[5.0.0]" - } - }, - "Microsoft.CodeAnalysis.CSharp.Workspaces": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", - "Microsoft.CodeAnalysis.Common": "[5.0.0]", - "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "System.Composition": "9.0.0" - } - }, - "Microsoft.CodeAnalysis.Workspaces.Common": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Common": "[5.0.0]", - "System.Composition": "9.0.0" - } - }, - "Microsoft.CodeAnalysis.Workspaces.MSBuild": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", - "dependencies": { - "Humanizer.Core": "2.14.1", - "Microsoft.Build.Framework": "17.11.31", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", - "Microsoft.Extensions.DependencyInjection": "9.0.0", - "Microsoft.Extensions.Logging": "9.0.0", - "Microsoft.Extensions.Logging.Abstractions": "9.0.0", - "Microsoft.Extensions.Options": "9.0.0", - "Microsoft.Extensions.Primitives": "9.0.0", - "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", - "Newtonsoft.Json": "13.0.3", - "System.Composition": "9.0.0" - } - }, - "Microsoft.EntityFrameworkCore.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" - }, - "Microsoft.EntityFrameworkCore.Analyzers": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" - }, - "Microsoft.EntityFrameworkCore.Relational": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" - } - }, - "Microsoft.EntityFrameworkCore.Sqlite.Core": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", - "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.3", - "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.Extensions.Caching.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Caching.Memory": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Configuration.Binder": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" - } - }, - "Microsoft.Extensions.Configuration.FileExtensions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Physical": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" - }, - "Microsoft.Extensions.Diagnostics": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } - }, - "Microsoft.Extensions.FileProviders.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.FileProviders.Physical": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.FileSystemGlobbing": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" - }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" - }, - "Microsoft.IdentityModel.Abstractions": { - "type": "Transitive", - "resolved": "8.16.0", - "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" - }, - "Microsoft.IdentityModel.Logging": { - "type": "Transitive", - "resolved": "8.16.0", - "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", - "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.16.0" - } - }, - "Microsoft.VisualStudio.SolutionPersistence": { - "type": "Transitive", - "resolved": "1.0.52", - "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" - }, - "Mono.TextTemplating": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", - "dependencies": { - "System.CodeDom": "6.0.0" - } - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" - }, - "Npgsql": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0" - } - }, - "SQLitePCLRaw.bundle_e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", - "dependencies": { - "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", - "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" - } - }, - "SQLitePCLRaw.core": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" - }, - "SQLitePCLRaw.lib.e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" - }, - "SQLitePCLRaw.provider.e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", - "dependencies": { - "SQLitePCLRaw.core": "2.1.11" - } - }, - "System.CodeDom": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" - }, - "System.Composition": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", - "dependencies": { - "System.Composition.AttributedModel": "9.0.0", - "System.Composition.Convention": "9.0.0", - "System.Composition.Hosting": "9.0.0", - "System.Composition.Runtime": "9.0.0", - "System.Composition.TypedParts": "9.0.0" - } - }, - "System.Composition.AttributedModel": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" - }, - "System.Composition.Convention": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", - "dependencies": { - "System.Composition.AttributedModel": "9.0.0" - } - }, - "System.Composition.Hosting": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", - "dependencies": { - "System.Composition.Runtime": "9.0.0" - } - }, - "System.Composition.Runtime": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" - }, - "System.Composition.TypedParts": { - "type": "Transitive", - "resolved": "9.0.0", - "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", - "dependencies": { - "System.Composition.AttributedModel": "9.0.0", - "System.Composition.Hosting": "9.0.0", - "System.Composition.Runtime": "9.0.0" - } - }, - "werkr.common": { - "type": "Project", - "dependencies": { - "Google.Protobuf": "[3.34.0, )", - "Microsoft.AspNetCore.Authorization": "[10.0.3, )", - "Microsoft.Extensions.Configuration.Abstractions": "[10.0.3, )", - "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", - "Microsoft.IdentityModel.Tokens": "[8.16.0, )", - "Werkr.Common.Configuration": "[1.0.0, )" - } - }, - "werkr.common.configuration": { - "type": "Project" - }, - "Google.Protobuf": { - "type": "CentralTransitive", - "requested": "[3.34.0, )", - "resolved": "3.34.0", - "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" - }, - "Microsoft.AspNetCore.Authorization": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", - "dependencies": { - "Microsoft.AspNetCore.Metadata": "10.0.3", - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } - }, - "Microsoft.Data.Sqlite.Core": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", - "dependencies": { - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Configuration.Json": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" - } - }, - "Microsoft.IdentityModel.Tokens": { - "type": "CentralTransitive", - "requested": "[8.16.0, )", - "resolved": "8.16.0", - "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.IdentityModel.Logging": "8.16.0" - } - } - } - } +{ + "version": 2, + "dependencies": { + "net10.0": { + "EFCore.NamingConventions": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "OPZ/u7fONQFmnyUIDB8SeJtKnyFkj1zJsZ0Ke2Cp17q8hYs6jGmYEFd6Ne4Hdcd6auUdFdV7di+uFo2w+L34NA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==", + "dependencies": { + "System.Memory": "4.5.3" + } + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA==" + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.AspNetCore.Authorization": "[10.0.3, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Microsoft.AspNetCore.Authorization": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + } + } + } } \ No newline at end of file diff --git a/src/Werkr.Server/packages.lock.json b/src/Werkr.Server/packages.lock.json index 081bf7a..46ecba5 100644 --- a/src/Werkr.Server/packages.lock.json +++ b/src/Werkr.Server/packages.lock.json @@ -1,512 +1,868 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "Grpc.Tools": { - "type": "Direct", - "requested": "[2.78.0, )", - "resolved": "2.78.0", - "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" - }, - "Microsoft.AspNetCore.App.Internal.Assets": { - "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "mr3Zn+ht8lijYvlMIasftw9opU9hsLKDdnOgQMmYI3RjWPJLOF9l8+YHDseRkTs97wOrULmJgo/NDCmzL/EGDg==" - }, - "Microsoft.IdentityModel.JsonWebTokens": { - "type": "Direct", - "requested": "[8.16.0, )", - "resolved": "8.16.0", - "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", - "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.16.0" - } - }, - "QRCoder": { - "type": "Direct", - "requested": "[1.7.0, )", - "resolved": "1.7.0", - "contentHash": "6R3hQkayihGIDjp3F1nLRDBWG+nqahGyOY2+fH4Rll16Vad67oaUUfHkOiMWKiJFnGh+PIGDfUos+0R9m54O1g==", - "dependencies": { - "System.Drawing.Common": "6.0.0" - } - }, - "Serilog.AspNetCore": { - "type": "Direct", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", - "dependencies": { - "Serilog": "4.3.0", - "Serilog.Extensions.Hosting": "10.0.0", - "Serilog.Formatting.Compact": "3.0.0", - "Serilog.Settings.Configuration": "10.0.0", - "Serilog.Sinks.Console": "6.1.1", - "Serilog.Sinks.Debug": "3.0.0", - "Serilog.Sinks.File": "7.0.0" - } - }, - "Serilog.Sinks.Console": { - "type": "Direct", - "requested": "[6.1.1, )", - "resolved": "6.1.1", - "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "Serilog.Sinks.File": { - "type": "Direct", - "requested": "[7.0.0, )", - "resolved": "7.0.0", - "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", - "dependencies": { - "Serilog": "4.2.0" - } - }, - "Serilog.Sinks.OpenTelemetry": { - "type": "Direct", - "requested": "[4.2.0, )", - "resolved": "4.2.0", - "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", - "dependencies": { - "Google.Protobuf": "3.30.1", - "Grpc.Net.Client": "2.70.0", - "Serilog": "4.2.0" - } - }, - "Grpc.Core.Api": { - "type": "Transitive", - "resolved": "2.70.0", - "contentHash": "66UotvWcSIq41oiQhLWcQACyKPM4umxXNiht5DQTLZJfNwEswWOcS7Z0xIEHyNIBE7ZpjotH22bEjTkvhPxmVw==" - }, - "Grpc.Net.Common": { - "type": "Transitive", - "resolved": "2.70.0", - "contentHash": "rBdEUMyCwa+iB8mqC6JKyPbj3SBHHkReJj/yy/XKJI63GcG6w9DJMMGTVcYHqq4Ci2W4m0HT4jt2pFfFscar8g==", - "dependencies": { - "Grpc.Core.Api": "2.70.0" - } - }, - "Microsoft.EntityFrameworkCore.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" - }, - "Microsoft.EntityFrameworkCore.Analyzers": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" - }, - "Microsoft.EntityFrameworkCore.Relational": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.3" - } - }, - "Microsoft.EntityFrameworkCore.Sqlite.Core": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", - "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.3", - "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.Extensions.AmbientMetadata.Application": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==" - }, - "Microsoft.Extensions.Compliance.Abstractions": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==" - }, - "Microsoft.Extensions.DependencyInjection.AutoActivation": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" - }, - "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==" - }, - "Microsoft.Extensions.FileProviders.Embedded": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "kw/xPl7m4Gv6bqx2ojihTtWiN2K2AklyMIrvncuSi2MOdwu0oMKoyh0G3p2Brt7m43Q9ER0IaA2G4EGjfgDh/w==" - }, - "Microsoft.Extensions.Http.Diagnostics": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", - "dependencies": { - "Microsoft.Extensions.Telemetry": "10.3.0" - } - }, - "Microsoft.Extensions.Resilience": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", - "dependencies": { - "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", - "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", - "Polly.Extensions": "8.4.2", - "Polly.RateLimiting": "8.4.2" - } - }, - "Microsoft.Extensions.ServiceDiscovery.Abstractions": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==" - }, - "Microsoft.Extensions.Telemetry": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", - "dependencies": { - "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", - "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", - "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" - } - }, - "Microsoft.Extensions.Telemetry.Abstractions": { - "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", - "dependencies": { - "Microsoft.Extensions.Compliance.Abstractions": "10.3.0" - } - }, - "Microsoft.IdentityModel.Abstractions": { - "type": "Transitive", - "resolved": "8.16.0", - "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" - }, - "Microsoft.IdentityModel.Logging": { - "type": "Transitive", - "resolved": "8.16.0", - "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", - "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.16.0" - } - }, - "Microsoft.Win32.SystemEvents": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A==" - }, - "Npgsql": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==" - }, - "OpenTelemetry": { - "type": "Transitive", - "resolved": "1.15.0", - "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", - "dependencies": { - "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" - } - }, - "OpenTelemetry.Api": { - "type": "Transitive", - "resolved": "1.15.0", - "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" - }, - "OpenTelemetry.Api.ProviderBuilderExtensions": { - "type": "Transitive", - "resolved": "1.15.0", - "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", - "dependencies": { - "OpenTelemetry.Api": "1.15.0" - } - }, - "Polly.Core": { - "type": "Transitive", - "resolved": "8.4.2", - "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" - }, - "Polly.Extensions": { - "type": "Transitive", - "resolved": "8.4.2", - "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", - "dependencies": { - "Polly.Core": "8.4.2" - } - }, - "Polly.RateLimiting": { - "type": "Transitive", - "resolved": "8.4.2", - "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", - "dependencies": { - "Polly.Core": "8.4.2" - } - }, - "Serilog": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" - }, - "Serilog.Extensions.Hosting": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", - "dependencies": { - "Serilog": "4.3.0", - "Serilog.Extensions.Logging": "10.0.0" - } - }, - "Serilog.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", - "dependencies": { - "Serilog": "4.2.0" - } - }, - "Serilog.Formatting.Compact": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "Serilog.Settings.Configuration": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", - "dependencies": { - "Microsoft.Extensions.DependencyModel": "10.0.0", - "Serilog": "4.3.0" - } - }, - "Serilog.Sinks.Debug": { - "type": "Transitive", - "resolved": "3.0.0", - "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", - "dependencies": { - "Serilog": "4.0.0" - } - }, - "SQLitePCLRaw.bundle_e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", - "dependencies": { - "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", - "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" - } - }, - "SQLitePCLRaw.core": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" - }, - "SQLitePCLRaw.lib.e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" - }, - "SQLitePCLRaw.provider.e_sqlite3": { - "type": "Transitive", - "resolved": "2.1.11", - "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", - "dependencies": { - "SQLitePCLRaw.core": "2.1.11" - } - }, - "System.Drawing.Common": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==", - "dependencies": { - "Microsoft.Win32.SystemEvents": "6.0.0" - } - }, - "werkr.common": { - "type": "Project", - "dependencies": { - "Google.Protobuf": "[3.34.0, )", - "Microsoft.IdentityModel.Tokens": "[8.16.0, )", - "Werkr.Common.Configuration": "[1.0.0, )" - } - }, - "werkr.common.configuration": { - "type": "Project" - }, - "werkr.data": { - "type": "Project", - "dependencies": { - "EFCore.NamingConventions": "[10.0.1, )", - "Microsoft.EntityFrameworkCore": "[10.0.3, )", - "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", - "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", - "Werkr.Common": "[1.0.0, )" - } - }, - "werkr.data.identity": { - "type": "Project", - "dependencies": { - "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.3, )", - "Microsoft.AspNetCore.Identity.UI": "[10.0.3, )", - "Werkr.Common": "[1.0.0, )", - "Werkr.Data": "[1.0.0, )" - } - }, - "werkr.servicedefaults": { - "type": "Project", - "dependencies": { - "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", - "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", - "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", - "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" - } - }, - "EFCore.NamingConventions": { - "type": "CentralTransitive", - "requested": "[10.0.1, )", - "resolved": "10.0.1", - "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" - } - }, - "Google.Protobuf": { - "type": "CentralTransitive", - "requested": "[3.34.0, )", - "resolved": "3.34.0", - "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" - }, - "Grpc.Net.Client": { - "type": "CentralTransitive", - "requested": "[2.76.0, )", - "resolved": "2.70.0", - "contentHash": "xNv0FFCVJa5S1beUtye82WFCxKThuE1jbN8DO1x1Rj8VSIWXLBUmfSID5a1fGzsU2R/EMfwPoWclJ2RMfQuGXw==", - "dependencies": { - "Grpc.Net.Common": "2.70.0" - } - }, - "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "6SEGWi35DZ9syBqCT8v5vEkm9tWUayWxVkHWLwW2FdyXSwS0zzEpIzGPLVQGeug3VU8d+hK/PFxFwwZnblv/zA==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Relational": "10.0.3" - } - }, - "Microsoft.AspNetCore.Identity.UI": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "xhxrP7QcUuyA2FcZsbvdHSqTauPseNrXzhFUYaRj+Elz1nxJceKbW+COc1P9QbpKeZDh9aTDSldHbz3AnMWOqg==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Embedded": "10.0.3" - } - }, - "Microsoft.Data.Sqlite.Core": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", - "dependencies": { - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.EntityFrameworkCore": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3" - } - }, - "Microsoft.EntityFrameworkCore.Sqlite": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", - "SQLitePCLRaw.core": "2.1.11" - } - }, - "Microsoft.Extensions.Http.Resilience": { - "type": "CentralTransitive", - "requested": "[10.3.0, )", - "resolved": "10.3.0", - "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", - "dependencies": { - "Microsoft.Extensions.Http.Diagnostics": "10.3.0", - "Microsoft.Extensions.Resilience": "10.3.0" - } - }, - "Microsoft.Extensions.ServiceDiscovery": { - "type": "CentralTransitive", - "requested": "[10.3.0, )", - "resolved": "10.3.0", - "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", - "dependencies": { - "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" - } - }, - "Microsoft.IdentityModel.Tokens": { - "type": "CentralTransitive", - "requested": "[8.16.0, )", - "resolved": "8.16.0", - "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", - "dependencies": { - "Microsoft.IdentityModel.Logging": "8.16.0" - } - }, - "Npgsql.EntityFrameworkCore.PostgreSQL": { - "type": "CentralTransitive", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", - "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", - "Npgsql": "10.0.0" - } - }, - "OpenTelemetry.Exporter.OpenTelemetryProtocol": { - "type": "CentralTransitive", - "requested": "[1.15.0, )", - "resolved": "1.15.0", - "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", - "dependencies": { - "OpenTelemetry": "1.15.0" - } - }, - "OpenTelemetry.Extensions.Hosting": { - "type": "CentralTransitive", - "requested": "[1.15.0, )", - "resolved": "1.15.0", - "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", - "dependencies": { - "OpenTelemetry": "1.15.0" - } - } - } - } +{ + "version": 2, + "dependencies": { + "net10.0": { + "Grpc.Tools": { + "type": "Direct", + "requested": "[2.78.0, )", + "resolved": "2.78.0", + "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" + }, + "Microsoft.AspNetCore.App.Internal.Assets": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "0gZrESKwnlmbE8Br8XIy3kk7Pj0++9T2Ly+A8BFYYgo5EgfqWEln26cho+l92KOaHUzclhvz314RiwE910s24g==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Direct", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "prBU72cIP4V8E9fhN+o/YdskTsLeIcnKPbhZf0X6mD7fdxoZqnS/NdEkSr+9Zp+2q7OZBOMfNBKGbTbhXODO4w==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "QRCoder": { + "type": "Direct", + "requested": "[1.7.0, )", + "resolved": "1.7.0", + "contentHash": "6R3hQkayihGIDjp3F1nLRDBWG+nqahGyOY2+fH4Rll16Vad67oaUUfHkOiMWKiJFnGh+PIGDfUos+0R9m54O1g==", + "dependencies": { + "System.Drawing.Common": "6.0.0" + } + }, + "Serilog.AspNetCore": { + "type": "Direct", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "Direct", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Direct", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "Serilog.Sinks.OpenTelemetry": { + "type": "Direct", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "PzMCyE5G19tjr5IZEi5qg+4UU5QrxBEoBEMu/hhYybTrGKXqUDiSGWKZNUDBgelaVKqLADlsmlJVyKce5SyPrg==", + "dependencies": { + "Google.Protobuf": "3.30.1", + "Grpc.Net.Client": "2.70.0", + "Serilog": "4.2.0" + } + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.70.0", + "contentHash": "66UotvWcSIq41oiQhLWcQACyKPM4umxXNiht5DQTLZJfNwEswWOcS7Z0xIEHyNIBE7ZpjotH22bEjTkvhPxmVw==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.70.0", + "contentHash": "rBdEUMyCwa+iB8mqC6JKyPbj3SBHHkReJj/yy/XKJI63GcG6w9DJMMGTVcYHqq4Ci2W4m0HT4jt2pFfFscar8g==", + "dependencies": { + "Grpc.Core.Api": "2.70.0" + } + }, + "Microsoft.AspNetCore.Cryptography.Internal": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "STkCfgCECt2cAekgBpXxFDefH5wd4ytYZKihIZSmQqY92BP8N9qN71qFyRpry8Sl/qT5A+bpwe8v7sjDtg5LEA==" + }, + "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c8GgMKpnNf8fUOKXaZXKV5XaLSlvAts8ICvcPr5CIfjHEWJtbq+URIfBGYesyhnOlWAiSgVsdCBZxMEJIHgfLw==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.3" + } + }, + "Microsoft.AspNetCore.Metadata": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Physical": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Features": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "djFt1Jt+2uREWWVQiiA4ilYBDtHHY7nK08c5K8xBD9+XFNw3KDVprylrMkH08bZGK3ZHRAkS7JDV9srfLrcm/g==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Embedded": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "kw/xPl7m4Gv6bqx2ojihTtWiN2K2AklyMIrvncuSi2MOdwu0oMKoyh0G3p2Brt7m43Q9ER0IaA2G4EGjfgDh/w==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "M5gWob3dtzlF14oto1lR1ZuSJrR0gGc+obv7zY9LGmX5y3Ndpve29MrrjqJW/m4CFud4TE/KFUuHjjtwxhCO8g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.Telemetry": "10.3.0" + } + }, + "Microsoft.Extensions.Identity.Core": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GdhTmz+BiVEdsFCT7Vqjhlx8q7j7kGPLinJjudPLO48DxZjSIwh9KlOd/AYJoGR21NjkkHiWijcB3RG7rIfMqw==", + "dependencies": { + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Identity.Stores": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "XWu+Xg0dc0VKJxW7iTuhpnSD2jqZ4Kcdr7f3vUf7LOmPkawBLGkUuUA3rl+QQCbXAGnomV/I9T2wTxe1BKkVEA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.3", + "Microsoft.Extensions.Identity.Core": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "PBlaoYeusaxNYyN4WFjzcXWlUDSvLUPxy/e6oP1SONOOYA/oBWT2uBmFGJMV9VTtXiXXxCB39LqlYWbsWE4UKA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "dQKlVXzqflsv5X8iDlAN5YmTL1GcLCrOLKo1s9PNdfjqxeu0S/jmWTfiLGno+8+o1qFL3+VFAH5/ftmypN+sPw==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.ServiceDiscovery.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.Binder": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Features": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3", + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.3.0", + "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "gSxKLWRZzBpIsEoeUPkxfywNCCvRvl7hkq146XHPk5vOQc9izSf1I+uL1vh4y2U19QPxd9Z8K/8AdWyxYz2lSg==" + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.16.0", + "contentHash": "MTzXmETkNQPACR7/XCXM1OGM6oU9RkyibqeJRtO9Ndew2LnGjMf9Atqj2VSf4XC27X0FQycUAlzxxEgQMWn2xQ==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.16.0" + } + }, + "Microsoft.Win32.SystemEvents": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "vk5OGdf6K9kQScCWo3bRjhDWCv6Pqw92IpX4dlARZ8B1WL7/2NGTDtCkkw42eQf7UdwyoHKzVvMH/PtL8d6z7w==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.0", + "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "OpenTelemetry.Api": "1.15.0" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, + "Serilog": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "DC4nA7yWnf4UZdgJDF+9Mus4/cb0Y3Sfgi3gDnAoKNAIBwzkskNAbNbyu+u4atT0ruVlZNJfwZmwiEwE5oz9LQ==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.11", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==", + "dependencies": { + "System.Memory": "4.5.3" + } + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Ev2ytaXiOlWZ4b3R67GZBsemTINslLD1DCJr2xiacpn4tbapu0Q4dHEzSvZSMnVWeE5nlObU3VZN2p81q3XOYQ==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.11", + "contentHash": "Y/0ZkR+r0Cg3DQFuCl1RBnv/tmxpIZRU3HUvelPw6MVaKHwYYR8YNvgs0vuNuXCMvlyJ+Fh88U1D4tah1tt6qw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "6.0.0" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.3", + "contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA==" + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "werkr.common": { + "type": "Project", + "dependencies": { + "Google.Protobuf": "[3.34.0, )", + "Microsoft.AspNetCore.Authorization": "[10.0.3, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.IdentityModel.Tokens": "[8.16.0, )", + "Werkr.Common.Configuration": "[1.0.0, )" + } + }, + "werkr.common.configuration": { + "type": "Project" + }, + "werkr.data": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Werkr.Common": "[1.0.0, )" + } + }, + "werkr.data.identity": { + "type": "Project", + "dependencies": { + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.3, )", + "Microsoft.AspNetCore.Identity.UI": "[10.0.3, )", + "Werkr.Common": "[1.0.0, )", + "Werkr.Data": "[1.0.0, )" + } + }, + "werkr.servicedefaults": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" + } + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Google.Protobuf": { + "type": "CentralTransitive", + "requested": "[3.34.0, )", + "resolved": "3.34.0", + "contentHash": "a5US9akiNczS5kC7qBqYqJmnxHVQDITZD6GRRbwGHk/oa17EwOGE3PHIWFVeHTqCctq8mVjLSelwsxCkYYBinA==" + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.76.0, )", + "resolved": "2.70.0", + "contentHash": "xNv0FFCVJa5S1beUtye82WFCxKThuE1jbN8DO1x1Rj8VSIWXLBUmfSID5a1fGzsU2R/EMfwPoWclJ2RMfQuGXw==", + "dependencies": { + "Grpc.Net.Common": "2.70.0", + "Microsoft.Extensions.Logging.Abstractions": "6.0.0" + } + }, + "Microsoft.AspNetCore.Authorization": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "dependencies": { + "Microsoft.AspNetCore.Metadata": "10.0.3", + "Microsoft.Extensions.Diagnostics": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3", + "Microsoft.Extensions.Options": "10.0.3" + } + }, + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "6SEGWi35DZ9syBqCT8v5vEkm9tWUayWxVkHWLwW2FdyXSwS0zzEpIzGPLVQGeug3VU8d+hK/PFxFwwZnblv/zA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.Identity.Stores": "10.0.3" + } + }, + "Microsoft.AspNetCore.Identity.UI": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xhxrP7QcUuyA2FcZsbvdHSqTauPseNrXzhFUYaRj+Elz1nxJceKbW+COc1P9QbpKeZDh9aTDSldHbz3AnMWOqg==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Embedded": "10.0.3", + "Microsoft.Extensions.Identity.Stores": "10.0.3" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", + "Microsoft.Extensions.Caching.Memory": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Microsoft.Extensions.Logging": "10.0.3", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.3" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.3", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.3.0", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Extensions.Resilience": "10.3.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + } + }, + "Microsoft.Extensions.ServiceDiscovery": { + "type": "CentralTransitive", + "requested": "[10.3.0, )", + "resolved": "10.3.0", + "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.3", + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.16.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", + "Npgsql": "10.0.0" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "VH8ANc/js9IRvfYt0Q2UaAxNCOWm+IU+vWrtoH7pfx4oWPVdISUt+9uWfBCFMWZg5WzQip5dhslyDjeyZXXfSQ==", + "dependencies": { + "OpenTelemetry": "1.15.0" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "OpenTelemetry": "1.15.0" + } + } + } + } } \ No newline at end of file From 6dd33e691c531efba5c7676d35f8673f2a0d6269 Mon Sep 17 00:00:00 2001 From: Taylor Marvin Date: Thu, 5 Mar 2026 15:11:46 -0800 Subject: [PATCH 06/37] Update src/Test/Werkr.Tests.Server/Identity/JwtTokenServiceTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Test/Werkr.Tests.Server/Identity/JwtTokenServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Test/Werkr.Tests.Server/Identity/JwtTokenServiceTests.cs b/src/Test/Werkr.Tests.Server/Identity/JwtTokenServiceTests.cs index b26cb51..9d6a16f 100644 --- a/src/Test/Werkr.Tests.Server/Identity/JwtTokenServiceTests.cs +++ b/src/Test/Werkr.Tests.Server/Identity/JwtTokenServiceTests.cs @@ -172,7 +172,7 @@ public void GenerateToken_SetsCorrectIssuerAndAudience( ) { } /// - /// Verifies that the generated JWT's expiration timestamp () is in the future (after ) is in the future (after ) and within 20 minutes, matching the configured 15-minute token lifetime with a /// reasonable tolerance. /// From 6167a4d1de2ca997a172fb52e6c2c9e6a5a051c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:19:39 +0000 Subject: [PATCH 07/37] Initial plan From c7c8c4e8e642be6ffea8fb48092053dc94dd8a02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:29:11 +0000 Subject: [PATCH 08/37] fix: qualify unresolved cref references in XML doc comments Co-authored-by: tsmarvin <57049894+tsmarvin@users.noreply.github.com> --- src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs | 2 +- .../Unit/Registration/BundleExpirationServiceTests.cs | 2 +- .../Werkr.Tests.Server/Identity/IdentityFlowTests.cs | 10 +++++----- .../Werkr.Tests.Server/Identity/IdentitySeederTests.cs | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs b/src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs index 0124239..8af9bb9 100644 --- a/src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs +++ b/src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs @@ -7,7 +7,7 @@ namespace Werkr.Tests.Agent.Helpers; /// -/// Fake action handler that always succeeds. Used by . +/// Fake action handler that always succeeds. Used by . /// internal sealed class SuccessHandler : IActionHandler { diff --git a/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs index d465773..3f514fc 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs @@ -117,7 +117,7 @@ await Task.Delay( } /// - /// Verifies that a non-pending bundle (e.g., ) is not modified by the expiration service. + /// Verifies that a non-pending bundle (e.g., ) is not modified by the expiration service. /// [TestMethod] public async Task ExecuteAsync_NonPendingBundles_NotModified( ) { diff --git a/src/Test/Werkr.Tests.Server/Identity/IdentityFlowTests.cs b/src/Test/Werkr.Tests.Server/Identity/IdentityFlowTests.cs index 1bbc7e4..013ea1c 100644 --- a/src/Test/Werkr.Tests.Server/Identity/IdentityFlowTests.cs +++ b/src/Test/Werkr.Tests.Server/Identity/IdentityFlowTests.cs @@ -18,7 +18,7 @@ namespace Werkr.Tests.Server.Identity; [TestClass] public class IdentityFlowTests { /// - /// Verifies that a user with the flag set to is redirected to + /// Verifies that a user with the flag set to is redirected to /// the "/account/change-password" page when the validates the cookie /// principal, regardless of the originally requested path. /// @@ -37,7 +37,7 @@ public async Task ForcedPasswordChange_RedirectsToChangePassword( ) { } /// - /// Verifies that a user with the flag set to who attempts to + /// Verifies that a user with the flag set to who attempts to /// navigate to a page other than "/account/change-password" (e.g., "/agents") is still redirected to the change /// password page and that the cookie is marked for renewal. /// @@ -58,7 +58,7 @@ public async Task ForcedPasswordChange_CannotAccessOtherPages( ) { /// /// Verifies that a successful password change via allows the flag on the to be cleared, confirming that the forced password + /// cref="WerkrUser.ChangePassword"/> flag on the to be cleared, confirming that the forced password /// reset workflow can complete end-to-end. /// [TestMethod] @@ -195,8 +195,8 @@ public async Task MfaVerify_RecoveryCode_ConsumesCode( ) { } /// - /// Verifies that a user with set to but set to is redirected to "/account/manage/mfa?required=true" + /// Verifies that a user with set to but set to is redirected to "/account/manage/mfa?required=true" /// during cookie principal validation, enforcing the admin MFA enrollment policy. /// [TestMethod] diff --git a/src/Test/Werkr.Tests.Server/Identity/IdentitySeederTests.cs b/src/Test/Werkr.Tests.Server/Identity/IdentitySeederTests.cs index 531b255..920ef98 100644 --- a/src/Test/Werkr.Tests.Server/Identity/IdentitySeederTests.cs +++ b/src/Test/Werkr.Tests.Server/Identity/IdentitySeederTests.cs @@ -96,9 +96,9 @@ public async Task SeedAsync_CreatesDefaultRoles( ) { /// /// Verifies that creates a default admin user with the email - /// "admin@werkr.local", display name "Default Admin", and the appropriate flags set: = , = , = , and = . + /// "admin@werkr.local", display name "Default Admin", and the appropriate flags set: = , = , = , and = . /// [TestMethod] public async Task SeedAsync_CreatesDefaultAdminUser( ) { From fb938fc54afcae169cff1bcc82d5e2c93fb99c1f Mon Sep 17 00:00:00 2001 From: Taylor Marvin Date: Sat, 7 Mar 2026 21:37:31 -0800 Subject: [PATCH 09/37] - Format files & run visual studio code cleanup. - Update hosted powershell to 7.6 rc1. - Added ascii art to the agent home page. - Added default migrations --- Directory.Packages.props | 4 +- .../Msi/CustomActions/packages.lock.json | 14 - .../Werkr.Tests.Agent.csproj | 1 + src/Test/Werkr.Tests.Agent/packages.lock.json | 639 ++++---- .../AgentConnectionManagerTests.cs | 4 +- .../Unit/Communication/KeyRotationTests.cs | 2 +- .../Unit/Communication/NullEncryptionTests.cs | 6 +- .../Communication/PayloadEncryptorTests.cs | 14 +- .../Cryptography/EncryptionProviderTests.cs | 6 +- .../Cryptography/HybridEncryptionTests.cs | 4 +- .../RegistrationBundlePayloadTests.cs | 6 +- .../Scheduling/ScheduleCalculatorTests.cs | 65 +- .../Unit/Scheduling/ScheduleServiceTests.cs | 14 +- .../Unit/Tasks/AgentResolverTests.cs | 20 +- .../Unit/Tasks/TaskServiceTests.cs | 16 +- .../Unit/Workflows/WorkflowExecutorTests.cs | 21 +- .../Unit/Workflows/WorkflowServiceTests.cs | 20 +- src/Werkr.Agent/Program.cs | 49 +- src/Werkr.Agent/packages.lock.json | 1045 ++++-------- src/Werkr.Api/Werkr.Api.csproj | 1 + src/Werkr.Api/packages.lock.json | 481 ++---- src/Werkr.AppHost/packages.lock.json | 19 +- .../Rendering/AnsiHtmlConverter.cs | 8 +- .../Communication/AgentConnectionManager.cs | 2 +- .../Communication/CommandDispatcher.cs | 2 +- .../CommandDispatcherException.cs | 2 +- .../Communication/GrpcOutputReader.cs | 2 +- .../Communication/ICommandDispatcher.cs | 2 +- .../Communication/KeyRotationService.cs | 2 +- .../Communication/OperatorOutput.cs | 2 +- .../Communication/PayloadEncryptor.cs | 2 +- .../Cryptography/EncryptionProvider.cs | 2 +- .../KeyInfo/AesGcmDecryptionData.cs | 2 +- .../KeyInfo/AesGcmDecryptionNote.cs | 2 +- .../Cryptography/KeyInfo/RSAKeyPair.cs | 2 +- .../Cryptography/WerkrCryptoException.cs | 2 +- .../Health/AgentHealthCheckService.cs | 2 +- src/Werkr.Core/Operators/IActionHandler.cs | 2 +- src/Werkr.Core/Operators/IActionOperator.cs | 2 +- src/Werkr.Core/Operators/IShellOperator.cs | 2 +- src/Werkr.Core/Operators/OperatorExecution.cs | 2 +- .../Registration/BundleExpirationService.cs | 2 +- .../Models/AgentRegistrationResult.cs | 2 +- .../Models/RegistrationBundlePayload.cs | 2 +- .../Models/RegistrationResponsePayload.cs | 2 +- .../RegistrationBundleGenerator.cs | 2 +- .../Registration/RegistrationService.cs | 2 +- .../Scheduling/HolidayCalculator.cs | 2 +- .../Scheduling/HolidayDateService.cs | 2 +- .../Scheduling/ScheduleCalculator.cs | 2 +- .../Scheduling/ScheduleDescriptionBuilder.cs | 2 +- .../Scheduling/ScheduleOccurrenceResult.cs | 2 +- src/Werkr.Core/Scheduling/ScheduleService.cs | 2 +- .../Scheduling/SuppressedOccurrence.cs | 2 +- src/Werkr.Core/Security/IFilePathResolver.cs | 2 +- src/Werkr.Core/Security/ISecretStore.cs | 2 +- src/Werkr.Core/Security/LinuxSecretStore.cs | 2 +- src/Werkr.Core/Security/MacOsSecretStore.cs | 2 +- src/Werkr.Core/Security/WindowsSecretStore.cs | 2 +- src/Werkr.Core/Tasks/AgentResolver.cs | 2 +- src/Werkr.Core/Tasks/JobExecutionService.cs | 2 +- src/Werkr.Core/Tasks/JobOutputWriter.cs | 2 +- .../Tasks/SuccessCriteriaEvaluator.cs | 2 +- src/Werkr.Core/Tasks/TaskService.cs | 2 +- .../Workflows/ConditionEvaluator.cs | 2 +- src/Werkr.Core/Workflows/WorkflowExecutor.cs | 2 +- .../Workflows/WorkflowRunTracker.cs | 2 +- src/Werkr.Core/Workflows/WorkflowService.cs | 2 +- .../20260308034043_InitialCreate.Designer.cs | 528 ++++++ .../Postgres/20260308034043_InitialCreate.cs | 340 ++++ ...gresWerkrIdentityDbContextModelSnapshot.cs | 525 ++++++ .../20260308034120_InitialCreate.Designer.cs | 516 ++++++ .../Sqlite/20260308034120_InitialCreate.cs | 297 ++++ ...liteWerkrIdentityDbContextModelSnapshot.cs | 513 ++++++ .../20260308033431_InitialCreate.Designer.cs | 1422 +++++++++++++++++ .../Postgres/20260308033431_InitialCreate.cs | 745 +++++++++ .../PostgresWerkrDbContextModelSnapshot.cs | 1419 ++++++++++++++++ .../20260308033950_InitialCreate.Designer.cs | 1408 ++++++++++++++++ .../Sqlite/20260308033950_InitialCreate.cs | 658 ++++++++ .../SqliteWerkrDbContextModelSnapshot.cs | 1405 ++++++++++++++++ .../Components/Layout/MainLayout.razor | 2 +- .../Components/Pages/Account/Manage/Mfa.razor | 2 +- .../Components/Pages/Agents/Index.razor | 2 +- src/Werkr.Server/Werkr.Server.csproj | 1 + src/Werkr.Server/packages.lock.json | 532 ++---- 85 files changed, 10908 insertions(+), 1956 deletions(-) create mode 100644 src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.Designer.cs create mode 100644 src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.cs create mode 100644 src/Werkr.Data.Identity/Migrations/Postgres/PostgresWerkrIdentityDbContextModelSnapshot.cs create mode 100644 src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.Designer.cs create mode 100644 src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.cs create mode 100644 src/Werkr.Data.Identity/Migrations/Sqlite/SqliteWerkrIdentityDbContextModelSnapshot.cs create mode 100644 src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.Designer.cs create mode 100644 src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.cs create mode 100644 src/Werkr.Data/Migrations/Postgres/PostgresWerkrDbContextModelSnapshot.cs create mode 100644 src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.Designer.cs create mode 100644 src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.cs create mode 100644 src/Werkr.Data/Migrations/Sqlite/SqliteWerkrDbContextModelSnapshot.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 1b97c66..c02ea67 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -55,7 +55,7 @@ - + @@ -67,4 +67,4 @@ - + \ No newline at end of file diff --git a/src/Installer/Msi/CustomActions/packages.lock.json b/src/Installer/Msi/CustomActions/packages.lock.json index e9377cc..be92e0c 100644 --- a/src/Installer/Msi/CustomActions/packages.lock.json +++ b/src/Installer/Msi/CustomActions/packages.lock.json @@ -2,15 +2,6 @@ "version": 1, "dependencies": { ".NETFramework,Version=v4.8.1": { - "Microsoft.NETFramework.ReferenceAssemblies": { - "type": "Direct", - "requested": "[1.0.3, )", - "resolved": "1.0.3", - "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", - "dependencies": { - "Microsoft.NETFramework.ReferenceAssemblies.net481": "1.0.3" - } - }, "System.Text.Json": { "type": "Direct", "requested": "[10.0.3, )", @@ -50,11 +41,6 @@ "System.Threading.Tasks.Extensions": "4.6.3" } }, - "Microsoft.NETFramework.ReferenceAssemblies.net481": { - "type": "Transitive", - "resolved": "1.0.3", - "contentHash": "Vv/20vgHS7VglVOVh8J3Iz/MA+VYKVRp9f7r2qiKBMuzviTOmocG70yq0Q8T5OTmCONkEAIJwETD1zhEfLkAXQ==" - }, "System.Buffers": { "type": "Transitive", "resolved": "4.6.1", diff --git a/src/Test/Werkr.Tests.Agent/Werkr.Tests.Agent.csproj b/src/Test/Werkr.Tests.Agent/Werkr.Tests.Agent.csproj index a66737c..bdd55f9 100644 --- a/src/Test/Werkr.Tests.Agent/Werkr.Tests.Agent.csproj +++ b/src/Test/Werkr.Tests.Agent/Werkr.Tests.Agent.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Test/Werkr.Tests.Agent/packages.lock.json b/src/Test/Werkr.Tests.Agent/packages.lock.json index 9350d5b..481f2af 100644 --- a/src/Test/Werkr.Tests.Agent/packages.lock.json +++ b/src/Test/Werkr.Tests.Agent/packages.lock.json @@ -2,6 +2,57 @@ "version": 2, "dependencies": { "net10.0": { + "Microsoft.PowerShell.SDK": { + "type": "Direct", + "requested": "[7.6.0-rc.1, )", + "resolved": "7.6.0-rc.1", + "contentHash": "0AAObi5+pcXKD+4CACnn30WXhe0cP9+5VcZeopAEpKTQRhPrBtYiS2ACMTy72oHJGojCcaX6n+7WW2xuTEd8dg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.3", + "Microsoft.Extensions.ObjectPool": "10.0.3", + "Microsoft.Management.Infrastructure.CimCmdlets": "7.6.0-rc.1", + "Microsoft.PowerShell.Commands.Diagnostics": "7.6.0-rc.1", + "Microsoft.PowerShell.Commands.Management": "7.6.0-rc.1", + "Microsoft.PowerShell.Commands.Utility": "7.6.0-rc.1", + "Microsoft.PowerShell.ConsoleHost": "7.6.0-rc.1", + "Microsoft.PowerShell.Security": "7.6.0-rc.1", + "Microsoft.WSMan.Management": "7.6.0-rc.1", + "Microsoft.Win32.Registry.AccessControl": "10.0.3", + "Microsoft.Win32.SystemEvents": "10.0.3", + "Microsoft.Windows.Compatibility": "10.0.3", + "System.CodeDom": "10.0.3", + "System.ComponentModel.Composition": "10.0.3", + "System.ComponentModel.Composition.Registration": "10.0.3", + "System.Configuration.ConfigurationManager": "10.0.3", + "System.Data.Odbc": "10.0.3", + "System.Data.OleDb": "10.0.3", + "System.Data.SqlClient": "4.9.0", + "System.Diagnostics.EventLog": "10.0.3", + "System.Diagnostics.PerformanceCounter": "10.0.3", + "System.DirectoryServices": "10.0.3", + "System.DirectoryServices.AccountManagement": "10.0.3", + "System.DirectoryServices.Protocols": "10.0.3", + "System.Drawing.Common": "10.0.3", + "System.IO.Packaging": "10.0.3", + "System.IO.Ports": "10.0.3", + "System.Management": "10.0.3", + "System.Management.Automation": "7.6.0-rc.1", + "System.Net.Http.WinHttpHandler": "10.0.3", + "System.Reflection.Context": "10.0.3", + "System.Runtime.Caching": "10.0.3", + "System.Security.Cryptography.Pkcs": "10.0.3", + "System.Security.Cryptography.ProtectedData": "10.0.3", + "System.Security.Cryptography.Xml": "10.0.3", + "System.Security.Permissions": "10.0.3", + "System.ServiceModel.Http": "10.0.652802", + "System.ServiceModel.NetFramingBase": "10.0.652802", + "System.ServiceModel.NetTcp": "10.0.652802", + "System.ServiceModel.Primitives": "10.0.652802", + "System.ServiceModel.Syndication": "10.0.3", + "System.ServiceProcess.ServiceController": "10.0.3", + "System.Speech": "10.0.3" + } + }, "MSTest": { "type": "Direct", "requested": "[4.1.0, )", @@ -52,30 +103,30 @@ }, "Json.More.Net": { "type": "Transitive", - "resolved": "2.0.2", - "contentHash": "izscdjjk8EAHDBCjyz7V7n77SzkrSjh/hUGV6cyR6PlVdjYDh5ohc8yqvwSqJ9+6Uof8W6B24dIHlDKD+I1F8A==" + "resolved": "2.1.1", + "contentHash": "ZXAKl2VsdnIZeUo1PFII3Oi1m1L4YQjEyDjygHfHln5vgsjgIo749X6xWkv7qFYp8RROES+vOEfDcvvoVgs8kA==" }, "JsonPointer.Net": { "type": "Transitive", - "resolved": "5.0.2", - "contentHash": "H/OtixKadr+ja1j7Fru3WG56V9zP0AKT1Bd0O7RWN/zH1bl8ZIwW9aCa4+xvzuVvt4SPmrvBu3G6NpAkNOwNAA==", + "resolved": "5.3.1", + "contentHash": "3e2OJjU0OaE26XC/klgxbJuXvteFWTDJIJv0ITYWcJEoskq7jzUwPSC1s0iz4wPPQnfN7vwwFmg2gJfwRAPwgw==", "dependencies": { "Humanizer.Core": "2.14.1", - "Json.More.Net": "2.0.1.2" + "Json.More.Net": "2.1.1" } }, "JsonSchema.Net": { "type": "Transitive", - "resolved": "7.2.3", - "contentHash": "O3KclMcPVFYTZsTeZBpwtKd/lYrNc3AFR+xi9j3Q4CfhDufOUx25TMMWJOcFRrqVklvKQ4Kl+0UhlNX1iDGoRw==", + "resolved": "7.4.0", + "contentHash": "5T3DWENwuCzLwFWz0qjXXVWA8+5+gC95OLkhqUBWpVpWBMr9gwfhWNeX8rWyr+fLQ7pIQ+lWuHIrmXRudxOOSw==", "dependencies": { - "JsonPointer.Net": "5.0.0" + "JsonPointer.Net": "5.3.1" } }, "Markdig.Signed": { "type": "Transitive", - "resolved": "0.38.0", - "contentHash": "zfi6kNm5QJnsCGm5a0hMG2qw8juYbOfsS4c1OuTcqkbYQUCdkam6d6Nt7nPIrbV4D+U7sHChidSQlg+ViiMPuw==" + "resolved": "0.44.0", + "contentHash": "mNxf8HrQA/clO8usqQhVc0BGlw0bJtZ76dic5KZGBPJZDX4UR67Jglwilkp5A//gPSMwcoY5EjLPppkZ/B4IMg==" }, "Microsoft.ApplicationInsights": { "type": "Transitive", @@ -89,8 +140,8 @@ }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + "resolved": "10.0.3", + "contentHash": "TV62UsrJZPX6gbt3c4WrtXh7bmaDIcMqf9uft1cc4L6gJXOU07hDGEh+bFQh/L2Az0R1WVOkiT66lFqS6G2NmA==" }, "Microsoft.CodeAnalysis.Analyzers": { "type": "Transitive", @@ -99,19 +150,19 @@ }, "Microsoft.CodeAnalysis.Common": { "type": "Transitive", - "resolved": "4.11.0", - "contentHash": "djf8ujmqYImFgB04UGtcsEhHrzVqzHowS+EEl/Yunc5LdrYrZhGBWUTXoCF0NzYXJxtfuD+UVQarWpvrNc94Qg==", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.3.4" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" } }, "Microsoft.CodeAnalysis.CSharp": { "type": "Transitive", - "resolved": "4.11.0", - "contentHash": "6XYi2EusI8JT4y2l/F3VVVS+ISoIX9nqHsZRaG6W5aFeJ5BEuBosHfT/ABb73FN0RZ1Z3cj2j7cL28SToJPXOw==", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.3.4", - "Microsoft.CodeAnalysis.Common": "[4.11.0]" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" } }, "Microsoft.CodeCoverage": { @@ -461,10 +512,10 @@ }, "Microsoft.Management.Infrastructure.CimCmdlets": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "p2nh2bDZGeAsOLd/QwRrZGahPV1Jy1Z0LNA/ZSqpyN8Cp31qh1UOfpmq4rss5P5deuygAN6DTLn96LY5oEDQpg==", + "resolved": "7.6.0-rc.1", + "contentHash": "+iB/Rj2xnjHo//4E+ADeQMc8LcWy8/XNRp3HqmIAeB/vPcCeEMdY3zald6nlJ90wQ9iZFRIpBKX3MM0IDMU6kg==", "dependencies": { - "System.Management.Automation": "7.5.4" + "System.Management.Automation": "7.6.0-rc.1" } }, "Microsoft.Management.Infrastructure.Runtime.Unix": { @@ -488,53 +539,50 @@ }, "Microsoft.PowerShell.Commands.Diagnostics": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "sRBHmXm2Ivy6pyAI2OX5PJ1DXbmmA1/OusFEXwdWWEjjiZ0prul3POc3GJoiMSn6WF5dJ6xw53MKZrkvu4uCgA==", + "resolved": "7.6.0-rc.1", + "contentHash": "gyy6F2m0USZOLJKZrSfagZzU6gwgHY0Ttz8UqY1U3VM3KDo7hE9fq1XDyDJ/5nxUkMjKv76x2YGZoyFCqKmaww==", "dependencies": { - "System.Management.Automation": "7.5.4" + "System.Management.Automation": "7.6.0-rc.1" } }, "Microsoft.PowerShell.Commands.Management": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "OhkLYDIf2xeexTWi+3yBRIrGMCpBBDPGAzKAp0wLCj3IE1D2H1Uj4XEE67y69eLFx7jxVwy2Er9hoTt5joECig==", + "resolved": "7.6.0-rc.1", + "contentHash": "CA3FbDTpKaXWFxTqRFSD6IblV7WWQ/8ru7+xYlOpuMX6F4L7mwYkVXtytLcYLeWwpiOX5Jo8L8TdENlS3PGeVA==", "dependencies": { - "Microsoft.PowerShell.Security": "7.5.4", - "System.Diagnostics.EventLog": "9.0.10", - "System.ServiceProcess.ServiceController": "9.0.10" + "Microsoft.PowerShell.Security": "7.6.0-rc.1", + "System.Diagnostics.EventLog": "10.0.3", + "System.ServiceProcess.ServiceController": "10.0.3" } }, "Microsoft.PowerShell.Commands.Utility": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "lvVh2zHEC2EnBImCRpu9b+5qqngE5o76gVI1NzIfReBXNtsym51XmX/kCrN0INm98CN3GoxTBa7WTcTJC1H3dw==", + "resolved": "7.6.0-rc.1", + "contentHash": "Bfece2H3c83JEoWQyrf6ka/OMEjJz4hS69GUzuLJOldtrRASnlJ8e6cIg3hjKomh2JTk+QjfL/Jt0GKTAz1nAw==", "dependencies": { - "Json.More.Net": "2.0.2", - "JsonPointer.Net": "5.0.2", - "JsonSchema.Net": "7.2.3", - "Markdig.Signed": "0.38.0", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.CSharp": "4.11.0", + "JsonSchema.Net": "7.4.0", + "Markdig.Signed": "0.44.0", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", "Microsoft.PowerShell.MarkdownRender": "7.2.1", - "Microsoft.Win32.SystemEvents": "9.0.10", - "System.Drawing.Common": "9.0.10", - "System.Management.Automation": "7.5.4" + "Microsoft.Win32.SystemEvents": "10.0.3", + "System.Drawing.Common": "10.0.3", + "System.Management.Automation": "7.6.0-rc.1" } }, "Microsoft.PowerShell.ConsoleHost": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "0U3DkO631KXj5m4jfsKQrUT795ZvZZAzvjNTNdhO4YNukwSSSzJUTszVVE2NXwbkQZHuAoUjPTwicINZJ87OoQ==", + "resolved": "7.6.0-rc.1", + "contentHash": "tzijTbDr1gUtSTx17013KEtJS9cqU+7FXF8LldxXQOlNRnxTgJBhg4aw88EXdBDVXFe9ON1d1YpD6BignLk+6Q==", "dependencies": { - "System.Management.Automation": "7.5.4" + "System.Management.Automation": "7.6.0-rc.1" } }, "Microsoft.PowerShell.CoreCLR.Eventing": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "1xyl5hcWKs5IDFO1ZWXSoVLPN78CJpo6GykVg3F/kNHkldixODi6yz1bbVmyEAMC64AvA3ZKSs/AZaGNoKTI+w==", + "resolved": "7.6.0-rc.1", + "contentHash": "4b0NmZ3mdlluet21RbUXkEMG9+aIY9f+BN2bLy/zmTeFq36MFkGE80JoY0NfIzwwJJA+NpfSj4vXWTYIwujr8Q==", "dependencies": { - "System.Diagnostics.EventLog": "9.0.10" + "System.Diagnostics.EventLog": "10.0.3" } }, "Microsoft.PowerShell.MarkdownRender": { @@ -547,15 +595,15 @@ }, "Microsoft.PowerShell.Native": { "type": "Transitive", - "resolved": "7.4.0", - "contentHash": "FlaJ3JBWhqFToYT0ycMb/Xxzoof7oTQbNyI4UikgubC7AMWt5ptBNKjIAMPvOcvEHr+ohaO9GvRWp3tiyS3sKw==" + "resolved": "700.0.0-rc.1", + "contentHash": "lJOCErHTSWwCzfp3wgeyqhNRi4t43McDc0CHqlbt3Cj3OomiqPlNHQXujSbgd+0Ir6/8QAmvU/VOYgqCyMki6A==" }, "Microsoft.PowerShell.Security": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "k/TMcn7ETkq91qhzncGbHthOEzZjGzcq6U6E4exyJRsRqe2MqaRXGrPifiCXDJ6I/dSQOclSpqFSqE/SWVUFdQ==", + "resolved": "7.6.0-rc.1", + "contentHash": "Lcsqavzb3jp9MqLFr9TCsxFBylepRGUhPhkRAYDeSMYohK6K4fMkdKudWo23M5ZKgooNySkkQX2H1jF7w0ZU7w==", "dependencies": { - "System.Management.Automation": "7.5.4" + "System.Management.Automation": "7.6.0-rc.1" } }, "Microsoft.Security.Extensions": { @@ -639,69 +687,67 @@ }, "Microsoft.Win32.Registry.AccessControl": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "ZYHfH0wgTa4usqMMetFYezSjfkQaMat83b/Ykz1q4qSx1h/OiXFb8ZSsn3ZKttHcxe1bn5m/+Zjz9deVT45L8w==" + "resolved": "10.0.3", + "contentHash": "CxgQc/IHtQ2IhqRdN6nxZZwk/C+dnbE5GLWErze4jAUgCkPbGe+hlgbESMop57FQMGShOqrWZWQAKpZ65QTU8g==" }, "Microsoft.Win32.SystemEvents": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "P1CEtsxar/RhfoH3r1vc9ra28LLVYphpcFBxyRIEMM/jP3qh4j9TU4sWH2RUhMZX+GbFxZ+zz1oSP2n9MwjshA==" + "resolved": "10.0.3", + "contentHash": "gYpwz5Gl0rs9pEFHBKctLbSi7SUGR4L1uRjXkU488nizWd2hvo2JP2+ATUsv0th7v6LDiXfdlUsnTbvC2MauFA==" }, "Microsoft.Windows.Compatibility": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "nCkAfadYeJNfJE/RoKGKFlIHVzovN6/DhLm4ebaCBiLWnP6R/fe22n3BWWqlkT2ignu+GTBrkNLs64e8yCCmGw==", - "dependencies": { - "Microsoft.Win32.Registry.AccessControl": "9.0.10", - "Microsoft.Win32.SystemEvents": "9.0.10", - "System.CodeDom": "9.0.10", - "System.ComponentModel.Composition": "9.0.10", - "System.ComponentModel.Composition.Registration": "9.0.10", - "System.Configuration.ConfigurationManager": "9.0.10", - "System.Data.Odbc": "9.0.10", - "System.Data.OleDb": "9.0.10", + "resolved": "10.0.3", + "contentHash": "MIoN5L2qs5cqClZ3vvH0ZG4QxaSEAYyNeR7B5k88y0l55MKAKCn4uSfposEwpXeHqZrI1uUevA/c5YUe6hcAIw==", + "dependencies": { + "Microsoft.Win32.Registry.AccessControl": "10.0.3", + "Microsoft.Win32.SystemEvents": "10.0.3", + "System.CodeDom": "10.0.3", + "System.ComponentModel.Composition": "10.0.3", + "System.ComponentModel.Composition.Registration": "10.0.3", + "System.Configuration.ConfigurationManager": "10.0.3", + "System.Data.Odbc": "10.0.3", + "System.Data.OleDb": "10.0.3", "System.Data.SqlClient": "4.9.0", - "System.Diagnostics.EventLog": "9.0.10", - "System.Diagnostics.PerformanceCounter": "9.0.10", - "System.DirectoryServices": "9.0.10", - "System.DirectoryServices.AccountManagement": "9.0.10", - "System.DirectoryServices.Protocols": "9.0.10", - "System.Drawing.Common": "9.0.10", - "System.IO.Packaging": "9.0.10", - "System.IO.Ports": "9.0.10", - "System.Management": "9.0.10", - "System.Reflection.Context": "9.0.10", - "System.Runtime.Caching": "9.0.10", - "System.Security.Cryptography.Pkcs": "9.0.10", - "System.Security.Cryptography.ProtectedData": "9.0.10", - "System.Security.Cryptography.Xml": "9.0.10", - "System.Security.Permissions": "9.0.10", - "System.ServiceModel.Duplex": "4.10.3", - "System.ServiceModel.Http": "4.10.3", - "System.ServiceModel.NetTcp": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3", - "System.ServiceModel.Security": "4.10.3", - "System.ServiceModel.Syndication": "9.0.10", - "System.ServiceProcess.ServiceController": "9.0.10", - "System.Speech": "9.0.10", - "System.Web.Services.Description": "4.10.3" + "System.Diagnostics.EventLog": "10.0.3", + "System.Diagnostics.PerformanceCounter": "10.0.3", + "System.DirectoryServices": "10.0.3", + "System.DirectoryServices.AccountManagement": "10.0.3", + "System.DirectoryServices.Protocols": "10.0.3", + "System.Drawing.Common": "10.0.3", + "System.IO.Packaging": "10.0.3", + "System.IO.Ports": "10.0.3", + "System.Management": "10.0.3", + "System.Reflection.Context": "10.0.3", + "System.Runtime.Caching": "10.0.3", + "System.Security.Cryptography.Pkcs": "10.0.3", + "System.Security.Cryptography.ProtectedData": "10.0.3", + "System.Security.Cryptography.Xml": "10.0.3", + "System.Security.Permissions": "10.0.3", + "System.ServiceModel.Http": "8.1.2", + "System.ServiceModel.NetTcp": "8.1.2", + "System.ServiceModel.Primitives": "8.1.2", + "System.ServiceModel.Syndication": "10.0.3", + "System.ServiceProcess.ServiceController": "10.0.3", + "System.Speech": "10.0.3", + "System.Web.Services.Description": "8.1.2" } }, "Microsoft.WSMan.Management": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "VaLrRXOuIlflS1zonDAbuKdADLojCeSdDy4d4vILa1l2SO3Yaheh3gGP3g4emPCeVDN75ZokigH8Ehe0OeNO1A==", + "resolved": "7.6.0-rc.1", + "contentHash": "AsZLXiO6qyrvmk6e4S4qabpbysq+5+veGmhDJUDMJZdyq0ORmFB7GiWD5aYHwSebKeJSItV51lwNcIdo1JgbQA==", "dependencies": { - "Microsoft.WSMan.Runtime": "7.5.4", - "System.Diagnostics.EventLog": "9.0.10", - "System.Management.Automation": "7.5.4", - "System.ServiceProcess.ServiceController": "9.0.10" + "Microsoft.WSMan.Runtime": "7.6.0-rc.1", + "System.Diagnostics.EventLog": "10.0.3", + "System.Management.Automation": "7.6.0-rc.1", + "System.ServiceProcess.ServiceController": "10.0.3" } }, "Microsoft.WSMan.Runtime": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "Kw5tys1LdJRl/Sn3qT5Os0VJev1o5TGPPfrd7SfxUFiHLcYeiO0IQGFpumZ9SXr4FxPot1125iu3l2a2VEEBZw==" + "resolved": "7.6.0-rc.1", + "contentHash": "HFG8NIyKCrZ7SM/lgewFpEQ13kbsRQm6c8ihdo2v+Am0XfftCw+cxzKOpNXlti8ZBEkPksZzkbxlPeAds0PZGA==" }, "MSTest.Analyzers": { "type": "Transitive", @@ -789,73 +835,73 @@ }, "runtime.android-arm.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "KUeHD0wRFCTS9QHantD5Cv/RzDzVY/mQP1Z/eKLtlX5A5SZvsqeomAoayPdh/QmgSzquoHeIDMAMp8VVU+Xzag==" + "resolved": "10.0.3", + "contentHash": "6W4qZX0X7FF+PHM9Kaa5ZsTLcGJAzCU7FB4Tjy1vTg2rUIEjDqijWTtpz8vY6gBzZaG+tD0/EKUyGnfq6d/d/Q==" }, "runtime.android-arm64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "b+z8JoBrZ5TMXiXeh0s+s9/uIVx6PmulEuMaN81JLM68aAb4DWHi7t5CL+8bWJhsFhd8VAYoZ9pi5miNTFPeuA==" + "resolved": "10.0.3", + "contentHash": "4OdNg2Du1kvm0b4tSErkA4wfH32YmgUqeSLzekk6NdCR61brCp4vttTJl0epZMwbRppXuy/jY7pV0WtP4ymnfQ==" }, "runtime.android-x64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "K7u+/G2gPoRLNc974p1Tnp44VRLlpQWZrKEQofBTpyJZPgd46ayvXayqT4jyGodG4O6Q6+yY2pYUYlqv1K2l6w==" + "resolved": "10.0.3", + "contentHash": "nP7xJSDSqRktV4kEd0kr4n0xhzJFuQjh7L/AgrKB3C4QK5TA/8NimJmTl1ogngvnXn2vilH2Hk+keBIvuoGWCA==" }, "runtime.android-x86.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "TGT4P40ockzrZf/K46A3VAl2dC2PWAS6WhqqtZJbH5G7XgBMX/FoaoY6DtFtz6u7RVl4zhjdG3QWXEv/u/1Hlg==" + "resolved": "10.0.3", + "contentHash": "f/neqijoVpgl/PcrXYeLNfCC2t74XYIcaHvtSaF3zaIdJ3hf5wrwRC9gzYLXHRLg/j4DRsCHEw8rlux/dNh7XA==" }, "runtime.linux-arm.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "Y2EEaUtO1JolypkFcqgsDxjmOleHa7d9OxBY4Osw5vIdQpOfP0Qj30czQfkN7cZTQH8NxsSr5WawVbk5yFabFg==" + "resolved": "10.0.3", + "contentHash": "cwiQmy95Zd42K4kMzDt8GkXNKHWDkRZIIyb3MteRrgJKubH2DMA6VY3JiFeR+1hQ7i31Ck+dYyt8wUkElZ/NrQ==" }, "runtime.linux-arm64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "nIFQGoz22wdgtmS4Ce+weqGUBh1kpO4XbNEgCU01+7P/+yZAb+gbRSeJUyUmCPhyW0S8FhX1xgJDH/SiJgP05Q==" + "resolved": "10.0.3", + "contentHash": "cEx+Xm0ZNPsuoYTFwXZ6qRstwXQ7vJjbb3jWzo5s5xIeEgpTDdfiUjTgK3Gl618mWgb7+Gn6Vt5pT07RxFC28Q==" }, "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "jV3esGC4j69yPlRzj50EbJq4syweBm4rOWKYJ3nWCMbVzTW1YQ2o4QhiVjCDOEKEf6q5eVGEaa6fyVXQ/K95Hw==" + "resolved": "10.0.3", + "contentHash": "kB21sNzlKgTj3V/LZhnTLFuViiGKtkiUG2GdEW+z3jdeUtRoxqBBXryzMqy0oUOy5idBW2pA6bh+iLGaj+GThA==" }, "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "UfoiSWuf75mYJPknRXSezDoFYaCp5dWoUjASjg6gQSa7FD2G59Mee6vMEzHFS+x8N+H8oNnL9TCIZUD4/8e/2Q==" + "resolved": "10.0.3", + "contentHash": "/Ud7EYdpnBGJ/x7DdqVFEJOQtEcf9rsD6/1iyUBJNfhhs8JljZLewJHp1cXr1DVCaWWc9jSWIc4g+QpMct/22A==" }, "runtime.linux-musl-arm.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "CWwqjMVVtYiW4I9wk9YuUaSxxkPZKZ/BKj5ppAsIZv4X9u/dyh8+Qbj3Fly61uUXpGxXU4QFQhYuPi5pJTAOBA==" + "resolved": "10.0.3", + "contentHash": "pBubukkmXD9e4Ju004bEHL3TE5mYahONCVwrJ5b+7/cj8smSY2H5sKTyURiVF1EfX55yORKyTyQ3dALA5yTTlg==" }, "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "NxMtoYPV8lreQggsXWsXXRF/djycKieThc4O2kxGB6EgjsiRDuNdnbODV0lWGV6v4mn08uQvuOhmBP5ZYpVdkA==" + "resolved": "10.0.3", + "contentHash": "+FR6SvpZya66Wv26MVEK4l2gnjfZnBVUz675eaTcMQ/KWAy+GMaPyhRaBIMnQJf5gfuu3x1qgeAzpmeFkOPGBw==" }, "runtime.linux-musl-x64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "ZNQ/D4lUGxTbfGAZRWP4vp7tCLqvInit03YXAiFXDWh/DnMEosBjrwcu8vbWgSsF01DUbyZQai8lwAStIZWo8w==" + "resolved": "10.0.3", + "contentHash": "Ee1XnOSKgeJGBcnd7LLLkyXEbE/JcpQ3pUlbPXqugl0YD55jVubpYebzGYI+ilrnBLCRiV0BR822b2XmerIKGw==" }, "runtime.linux-x64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "mOTksL8qwN9EiWz71Dzhc98iQhKmtHlWH5GbhiCJ+ES2ei1HLr2bXSNMrXRd5s0Wfzg6xeQmT5M9umcgtv8Bzg==" + "resolved": "10.0.3", + "contentHash": "U+Zr1KuBDIIwgR2gTMFbBAtkr4WnKdEXxmknPa3X8Qy6oCi4+MTRmq2zmTh7wxXQXTdmhfXg2tyy7DuVHtB+xQ==" }, "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "aKoLfCdLoQGhPh2VYBn6sn1kDuwgtXKJ4D2Ql/2WLCGCuXestpxLBS0JhSVBFSp1HrFdazj4aSwpYurtes+1Gg==" + "resolved": "10.0.3", + "contentHash": "9yTgh3ZKCBTGEO4YS76g/K15s2qSdeEZDqWteixUPeRsETsuBUwUPF39Fk39OPlRgwhqEuhtHy1Hf6BSOETfAg==" }, "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "B+boSbUptH2fiKiUXBIu5hlQ3oH+nAdPY9TNmpU10nuoFW/DNz21fCXY3UOIz9oRXp5Ao2b7RlurgpBl0AgoOQ==" + "resolved": "10.0.3", + "contentHash": "0gxqUZYQ1T1o5VzgyEMKiSPKJtPbleF2nbjZa5QKZQvPgSlrZFL+FrW4LywIk2scOpXu+Vd5Mv8AGGV7slhYWA==" }, "runtime.native.System.Data.SqlClient.sni": { "type": "Transitive", @@ -869,36 +915,36 @@ }, "runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "7AzXN+J8PkTVctfymH+tEaAmj0wNKFPyACqp5cYff0DrHxnsQhv7xtRWxJRrQ0azOAFGR1mhWN4aM1QkbQQ0Rw==", - "dependencies": { - "runtime.android-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-x86.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.osx-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.osx-x64.runtime.native.System.IO.Ports": "9.0.10" + "resolved": "10.0.3", + "contentHash": "7U3HW0JzAeg9PWaYBcyWLFMq2RSexgJ8uVCR3E2QVJJ/IZnRT0wYRhyEx3zBSbKYzN3KE6MDiRd6CN4hR7YkqA==", + "dependencies": { + "runtime.android-arm.runtime.native.System.IO.Ports": "10.0.3", + "runtime.android-arm64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.android-x64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.android-x86.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-arm.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-arm64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-x64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.osx-arm64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.osx-x64.runtime.native.System.IO.Ports": "10.0.3" } }, "runtime.osx-arm64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "m+gRRrmCTwP30YiVnFeZg/zRWgzVcOlN28cIPMkK11C9UU60waLknTnRLlQUagIkWaCDifKJCB6wtEeca5QiMA==" + "resolved": "10.0.3", + "contentHash": "qnEjXlIazxaRAhBet0EupWoJQ9PKsXjThpMKGP/ZsCjZgHEb+zs9wft82wY27OagyPOCg9D4hJHWKt5LTbJNZg==" }, "runtime.osx-x64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "89HgF1Oplzjomn0BLeqJxEC17d//zmbs7CMKT12ZvjvFMvpMFO8uQUZ9xRIh91rM0ByfbhSobe2IRezjpeDNlg==" + "resolved": "10.0.3", + "contentHash": "L1gcOL5kcfeeujKQbqJmpeTLsT0c6DeaJ9w6PY9oZ+/WESyya+Zj7StKguXR5j93o3o86FjcN05yufpoCz7R2g==" }, "runtime.win-arm64.runtime.native.System.Data.SqlClient.sni": { "type": "Transitive", @@ -996,44 +1042,44 @@ }, "System.CodeDom": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "00dAIR9Zx+F+AaipjaQmudX3VVpzYvT0bKVD3WcJq6om6pKNrldnp5bSR0VV6IlwDBa1HObGD+sTFaT/I9bBng==" + "resolved": "10.0.3", + "contentHash": "+G1mBhHJp8taiDcHK1gmckOZ884n9JeIrS1dzFYZhSa8oTiIF+EagIyAyCOQzdBbbES7eb9AkjGBlUZqqoCaeg==" }, "System.ComponentModel.Composition": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "tLJKLlc3VsjTLZ4aAwKicKfLKTAzTSSod+T6TWQSjmmA2JMgVvsU5QA2Ka2+Gq2M8poLaxY2dAipFsJen+ZI/g==" + "resolved": "10.0.3", + "contentHash": "qBShXNCMMY4rTWvh8Y9BombIyng4LUqbfZd3yx93D88YKf2w1MuYpC8J4XSxSVQDubcoB9AMUwWe6IcVzo3/Sg==" }, "System.ComponentModel.Composition.Registration": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "H+iSxY02ucdevQa+4jc5disuSgiLom2gUrdATFmVFWc/1De5HBtssVdcar2mxDbtT5IBKiMvwXVHrnl5jmaQtw==", + "resolved": "10.0.3", + "contentHash": "Dtb5UBzyHH3RnVbVSOTE62X6eIYmbStV3z4jk7aew34YVTMltAeqN6p5KffqdOo8MO8ARFmpZZDcfwJeIbuGsQ==", "dependencies": { - "System.ComponentModel.Composition": "9.0.10", - "System.Reflection.Context": "9.0.10" + "System.ComponentModel.Composition": "10.0.3", + "System.Reflection.Context": "10.0.3" } }, "System.Configuration.ConfigurationManager": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "5CBhl5dWmckKEtvk8F6GXtmHxNBoqAC8xILxIntNm7AzHiXQ09CXSLhncIJ/cQWaiNYzLjHZCgtMfx9tkCKHdA==", + "resolved": "10.0.3", + "contentHash": "69ZT/MYxQSxwdiiHRtI08noXiG5drj/bXDDZISmeWkNUtbIfYgmTiof16tCVOLTdmSQY7W7gwxkMliKdreWHGQ==", "dependencies": { - "System.Diagnostics.EventLog": "9.0.10", - "System.Security.Cryptography.ProtectedData": "9.0.10" + "System.Diagnostics.EventLog": "10.0.3", + "System.Security.Cryptography.ProtectedData": "10.0.3" } }, "System.Data.Odbc": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "1GjZfLbeSdfHhKUFhk4oU6f3PSF2DOFILTPLHDuC8Pj7UWvwnl8a+H7LDtwEqIJuZ0O2n0rMjydm+Fn67u0G2w==" + "resolved": "10.0.3", + "contentHash": "/uKHUwmcXdq06LisgTUC85pKx8xFwFBWKoVLwawW88mKIrUXMCtDxE8c1bixoHOWCI2tesvmZuxo6EgyDVZO/w==" }, "System.Data.OleDb": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "LwiN01NosLlqowmrD1ej1qM1O3GVZeQZzbWrTwYLyeQUGyTVt8yVsTgsRnIJmKny1ENdVcQ9WhKUjzBnh37fsQ==", + "resolved": "10.0.3", + "contentHash": "/YVeBa7iHwhvokst2neLLVBwhcTEcOSjLZPPhjBEPHVxkY5WsffnJ6pya2DhdaKsbCYsWAJfyvnoAPGIE1gwYA==", "dependencies": { - "System.Configuration.ConfigurationManager": "9.0.10", - "System.Diagnostics.PerformanceCounter": "9.0.10" + "System.Configuration.ConfigurationManager": "10.0.3", + "System.Diagnostics.PerformanceCounter": "10.0.3" } }, "System.Data.SqlClient": { @@ -1046,199 +1092,179 @@ }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "Jc+az1pTMujPLDn2j5eqSfzlO7j/T1K/LB7THxdfRWOxujE4zaitUqBs7sv1t6/xmmvpU6Xx3IofCs4owYH0yQ==" + "resolved": "10.0.3", + "contentHash": "+bZnyzt0/vt4g3QSllhsRNGTpa09p7Juy5K8spcK73cOTOefu4+HoY89hZOgIOmzB5A4hqPyEDKnzra7KKnhZw==" }, "System.Diagnostics.PerformanceCounter": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "35eXaOLXv8ATGDVr946gK0sNEEOwuFzhjFjTQftWh0swhLiyIjAD1pu17tu/SVENpKPZwqJ2e7IIcLpIs0GEzQ==", + "resolved": "10.0.3", + "contentHash": "pQHdkk3QNDYRsNzWVUIamlTiHb3/I50PxGXMRaEqiLmUe+FkInuMj92lCOhvTT6KbLlnMWGRgZq1rDaMJdQixw==", "dependencies": { - "System.Configuration.ConfigurationManager": "9.0.10" + "System.Configuration.ConfigurationManager": "10.0.3" } }, "System.DirectoryServices": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "dlSYvBLD/XlW2y7hJA+INfcRRtkouFSEcYSVoYmxwfurVdYJ088+PUYf8kgszAp3cThpMPAPVhNHl1lMYrv9kw==" + "resolved": "10.0.3", + "contentHash": "sqjtVOs35ortRSABZBhUq1YYtP9ScGMyix+F12K214KkAnfL1D8dS3oXl4U5uA4tsuP/WAiFf3rMvktkh/x1hA==" }, "System.DirectoryServices.AccountManagement": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "5sNlMrUPhEH8gmosdAz2ZuKA4S4fBdnkpgw5C9IIgyZzy8xg8wPj9aX5oBhoep48tqDVz0++DBWJxJsi4UjT+A==", + "resolved": "10.0.3", + "contentHash": "mllqB05qE2q9AHpyy5yKMSdz66AKCYQPP1I7Ny4+YjaMy7ttI91Ga/NWjOf7iPCXv1PHvgKHVtDrltm7yGKJPw==", "dependencies": { - "System.Configuration.ConfigurationManager": "9.0.10", - "System.DirectoryServices": "9.0.10", - "System.DirectoryServices.Protocols": "9.0.10" + "System.Configuration.ConfigurationManager": "10.0.3", + "System.DirectoryServices": "10.0.3", + "System.DirectoryServices.Protocols": "10.0.3" } }, "System.DirectoryServices.Protocols": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "nyJa6GTsPxNYt08Ssl9xHXLyDGozVkmsWgmAegUw9+4TBvS8BO1oV69XlkbyF+oJ6qR4+VPy7lgDWUMapvQfUg==" + "resolved": "10.0.3", + "contentHash": "LsmWlhBLelsI0+oHjgZd+WeeX/60TazKv/7xE+z88zONMerx2JWklfmpmOO6VHr1sys5bIJWHtjxhjRruVa4jg==" }, "System.Drawing.Common": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "FDakPhIcxHnhslLiz4ZQ+ALpHRpCU3zOep9Mcq+4hL23XwQrzmgJNYvf1tH4kJ/V36wO/ZhRr8nOfiz26P3wKg==", + "resolved": "10.0.3", + "contentHash": "bJlT89G7EqMpiLCvIPmb7BHpcSxjVeFsahRyQk3/mrUt5YgHKiFcEv88db97gKcpRn5opxfBwI0ohUmJlxuGJg==", "dependencies": { - "Microsoft.Win32.SystemEvents": "9.0.10" + "Microsoft.Win32.SystemEvents": "10.0.3" } }, "System.IO.Packaging": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "dKlnLbyOKFCLa5rda8yUU6M0HhVLMkB7rf9lEWnXVtHdNlq9A/fJmt7s/OhwbYaUfOO8rxshpQLyPn0Pv1a2lQ==" + "resolved": "10.0.3", + "contentHash": "/4CRIbxg4yhfUOCUjochR+iZKLUnewZ4gd3y9iQlKnFLzHkVFYEaO/WARPVgSBZ9W3+yCrERxlA2/4FXOm80og==" }, "System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "jMvwu+NOk/+vlOzTp9vpxIeGq+yRA+3EbkmpLMs37AAy9cI8YlY/ntTHL00w26Tvu6cIkx0/TdjmeHm0l99Nqw==", + "resolved": "10.0.3", + "contentHash": "Zs04mZ/dQtaFQ+hpQNDtijBs+6aM9j2fQPp8zNZTfh8DboVNNv7Sw6gH00hT+PVcAhEozlfT+n59Svm6Ug8ROA==", "dependencies": { - "runtime.native.System.IO.Ports": "9.0.10" + "runtime.native.System.IO.Ports": "10.0.3" } }, "System.Management": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "kJY2C6MjKSqfRkEnc8gn4Jth81Anrgxxpu0MffjEadfpp0Ll/gdGpYnDhRWZd+iFttkfZC0uCjFmCrZARRqq4w==", + "resolved": "10.0.3", + "contentHash": "VyK/nnG1ZgwP/wY8HGrNrdha7kenyI+bkfE0miywiRaWVwqtenuYshA6pmP6Xm7lPsTE2ZRZ+BkzmUVg/VZtTg==", "dependencies": { - "System.CodeDom": "9.0.10" + "System.CodeDom": "10.0.3" } }, "System.Management.Automation": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "kHvz4Gc2sQ670KNU+CMsCmoxSM+hO+qW9ujyf3MbBuDImuKeHL8oo2gq4kZpuncO/MSeOTstx3pW8YE6jqIZYA==", + "resolved": "7.6.0-rc.1", + "contentHash": "1uKnZxJh3AHXo5tM9Isv3kBlMZPIcXUv3fP46E3/L1I1oCleeYgD1uewdarAGbs1Z2B3qXzIxdM+5WkeoKi7GQ==", "dependencies": { - "Microsoft.ApplicationInsights": "2.22.0", + "Microsoft.ApplicationInsights": "2.23.0", "Microsoft.Management.Infrastructure": "3.0.0", - "Microsoft.PowerShell.CoreCLR.Eventing": "7.5.4", - "Microsoft.PowerShell.Native": "7.4.0", + "Microsoft.PowerShell.CoreCLR.Eventing": "7.6.0-rc.1", + "Microsoft.PowerShell.Native": "700.0.0-rc.1", "Microsoft.Security.Extensions": "1.4.0", - "Microsoft.Win32.Registry.AccessControl": "9.0.10", + "Microsoft.Win32.Registry.AccessControl": "10.0.3", "Newtonsoft.Json": "13.0.4", - "System.CodeDom": "9.0.10", - "System.Configuration.ConfigurationManager": "9.0.10", - "System.Diagnostics.EventLog": "9.0.10", - "System.DirectoryServices": "9.0.10", - "System.Management": "9.0.10", - "System.Security.Cryptography.Pkcs": "9.0.10", - "System.Security.Cryptography.ProtectedData": "9.0.10", - "System.Security.Permissions": "9.0.10", - "System.Windows.Extensions": "9.0.10" + "System.CodeDom": "10.0.3", + "System.Configuration.ConfigurationManager": "10.0.3", + "System.Diagnostics.EventLog": "10.0.3", + "System.DirectoryServices": "10.0.3", + "System.Management": "10.0.3", + "System.Security.Cryptography.Pkcs": "10.0.3", + "System.Security.Cryptography.ProtectedData": "10.0.3", + "System.Security.Permissions": "10.0.3", + "System.Windows.Extensions": "10.0.3" } }, "System.Net.Http.WinHttpHandler": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "D7CvYoTJPp/gDP3CMKxyUXUpfs8pFi4mQs+USHlT3Bqq6b83Lqe7gOn/dVPVZ78d2/cimxcqnpB9N2f1cDllWg==" - }, - "System.Private.ServiceModel": { - "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "BcUV7OERlLqGxDXZuIyIMMmk1PbqBblLRbAoigmzIUx/M8A+8epvyPyXRpbgoucKH7QmfYdQIev04Phx2Co08A==", - "dependencies": { - "Microsoft.Bcl.AsyncInterfaces": "5.0.0", - "Microsoft.Extensions.ObjectPool": "5.0.10", - "System.Security.Cryptography.Xml": "6.0.1" - } + "resolved": "10.0.3", + "contentHash": "BjcA6p1XJk9prN0Ekrg74SLxeXWVNjbdY4L9WWCbSbKxCeLodOWZROKOHn5M1kJomfLn5a4aqFcVD57/6IQStg==" }, "System.Reflection.Context": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "Dv7cY++FuibtTyQfWR7ZVMjdtblYkRH6po+UiyBsUwNri2T+afSqwpZq4F2zsVGxtsNsZpXbrJCDs4PxvwxMrQ==" + "resolved": "10.0.3", + "contentHash": "5uMIsgbNDFKFL5G+N/3S+nJBxxJKOQOrCLYrPMfgzqjgFADiBr8EDOPUFY+YS4KPcFduOJnqXvhLfqYIZeNJVQ==" }, "System.Runtime.Caching": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "WFKbtzR8mfIZWeQlYGtyjMcse3DoNR0zLsNAev2dDYM8pY945EzzLPO84qnVa+BIEDF1woD8+TtboWSh65U2DQ==", + "resolved": "10.0.3", + "contentHash": "+93HrquK0Wen9JL/Suli4mlyaVT/7Fo3mJPp3ozRvBskGx5rgRX5rNqna8XwvHTzCad9YP2wt5H3m2l0KysECQ==", "dependencies": { - "System.Configuration.ConfigurationManager": "9.0.10" + "System.Configuration.ConfigurationManager": "10.0.3" } }, "System.Security.Cryptography.Pkcs": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "Pg7QZz80fOJZrtJnAdEAIpeor8q7F1ofwXGYgLNr4dR8Mqf2l7lfeTaodQkRetrj+ClQwVVYoyi6g2eOsmstFw==" + "resolved": "10.0.3", + "contentHash": "Vwbm2siKxaGl515m/5C32J4VCG6VmytrH2ACV6hcWtfj4XQ1zN0cjuuDs49QoDi6/QS3pl2/wPyDYgODO9KxYA==" }, "System.Security.Cryptography.Xml": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "kkEBXInhetgK1+E0NzDSz4S2Yh3wpivGf1A7I88dN4SYINGrQnGciGDJj1RTgsE/zFeJNlAZhXs4XSqn7q8AhQ==", + "resolved": "10.0.3", + "contentHash": "Egnmhk/Im38UtSidZtqg0IOB6xQEpn56WR0j+od4qHQ7BUfo6JvqLBQsUSCHMEZ0kc7TIVGSH7aiAOick25Sfg==", "dependencies": { - "System.Security.Cryptography.Pkcs": "9.0.10" + "System.Security.Cryptography.Pkcs": "10.0.3" } }, "System.Security.Permissions": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "uqzXSkn2nx9nplIdayurMtbLcQQdOGd7TmIQ+X5P65+QWT2S+1aUZfJuH2f+Blr/4W6wxMkiX9aKzLk7lfMZFQ==", + "resolved": "10.0.3", + "contentHash": "kuqE2IzQ4lE92519tV9z5asKOXk/budCwryp1C53zCdaUls0+mWlGfmxiiQOpOkVkPPeIx1U7yCswaknBjhEOQ==", "dependencies": { - "System.Windows.Extensions": "9.0.10" + "System.Windows.Extensions": "10.0.3" } }, - "System.ServiceModel.Duplex": { + "System.ServiceModel.Http": { "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "IZ8ZahvTenWML7/jGUXSCm6jHlxpMbcb+Hy+h5p1WP9YVtb+Er7FHRRGizqQMINEdK6HhWpD6rzr5PdxNyusdg==", + "resolved": "10.0.652802", + "contentHash": "G02XZvmccf42QCU5MjviBIg69MSMAVHwL1inVPsNSpfp5g+t5BkQM3DyvWRLN4qmeFDWSF/mA1rIYONIDu/6Dg==", "dependencies": { - "System.Private.ServiceModel": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3" + "System.ServiceModel.Primitives": "10.0.652802" } }, - "System.ServiceModel.Http": { + "System.ServiceModel.NetFramingBase": { "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "hodkn0rPTYmoZ9EIPwcleUrOi1gZBPvU0uFvzmJbyxl1lIpVM5GxTrs/pCETStjOXCiXhBDoZQYajquOEfeW/w==", + "resolved": "10.0.652802", + "contentHash": "8/wx/Xnfm9LmGmK0banr05JJYNZmJzlxa8J5lfR7v3MM78QzSG8C3/HDi0/BjlOMeMZd21sX7oEFUhoucrk49w==", "dependencies": { - "System.Private.ServiceModel": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3" + "System.ServiceModel.Primitives": "10.0.652802" } }, "System.ServiceModel.NetTcp": { "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "tP7GN7ehqSIQEz7yOJEtY8ziTpfavf2IQMPKa7r9KGQ75+uEW6/wSlWez7oKQwGYuAHbcGhpJvdG6WoVMKYgkw==", + "resolved": "10.0.652802", + "contentHash": "VFQgu0IRWUPuPTxHZkMmhPNGYqcu9RwpFcZpW5L941dunUY8nJAErtAWEZYKnj2zAWsm/88nLAEoFc4cuoC2zw==", "dependencies": { - "System.Private.ServiceModel": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3" + "System.ServiceModel.NetFramingBase": "10.0.652802", + "System.ServiceModel.Primitives": "10.0.652802" } }, "System.ServiceModel.Primitives": { "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "aNcdry95wIP1J+/HcLQM/f/AA73LnBQDNc2uCoZ+c1//KpVRp8nMZv5ApMwK+eDNVdCK8G0NLInF+xG3mfQL+g==", + "resolved": "10.0.652802", + "contentHash": "ULfGNl75BNXkpF42wNV2CDXJ64dUZZEa8xO2mBsc4tqbW9QjruxjEB6bAr4Z/T1rNU+leOztIjCJQYsBGFWYlw==", "dependencies": { - "System.Private.ServiceModel": "4.10.3" - } - }, - "System.ServiceModel.Security": { - "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "vqelKb7DvP2inb6LDJ5Igi8wpOYdtLXn5luDW5qEaqkV2sYO1pKlVYBpr6g6m5SevzbdZlVNu67dQiD/H6EdGQ==", - "dependencies": { - "System.Private.ServiceModel": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3" + "Microsoft.Extensions.ObjectPool": "10.0.0", + "System.Security.Cryptography.Xml": "10.0.0" } }, "System.ServiceModel.Syndication": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "jWOXgKi51ULlPDi+YIWsZglIYUYC1DixAs2j6xdy8fzhuxvXO82yUEXv4wFziqzoG1FmTAV/uv5psxb+3MqB7w==" + "resolved": "10.0.3", + "contentHash": "mloJBxfbjYXgfcfMvH40UWwzekATlUzHLMKPQpg4qab+gpHRgQkhgo4DLz3v7Kacn6000sqoNkxiyk0pJ5x/ig==" }, "System.ServiceProcess.ServiceController": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "dmH+qHQ5wMjvEI0M2s6J+vmaU9L9ID2D9DWMFa7FiTfINfo3e3zeL4ljX7Dg5gCnFIULPFip2ej2iIAC3X6MFw==", + "resolved": "10.0.3", + "contentHash": "lOvEbCrTMl4EB7Ckp1suhgcnUEwUs2qRWLYZvquKU33hpRLZfjTXBSHFWQiRshxqd7dA+Nj9Yiw8EbOwd3T/eQ==", "dependencies": { - "System.Diagnostics.EventLog": "9.0.10" + "System.Diagnostics.EventLog": "10.0.3" } }, "System.Speech": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "rtbgAR0AD2yij7tqh/TJFAvsr1KN+Q8hb8JUcAN7uLh5EAkQ8Z4o7bFTQpcZDPec3/KsBFPHZNQS0nTLHEdmwQ==" + "resolved": "10.0.3", + "contentHash": "1n6Sn16yLtonZSK7tGzKiGnanuCs38kiE0EIbssNoC6+S8Bx2iih0jNcvU9KxNeRYwMQYXlQUVAUhrkYS+CSHQ==" }, "System.Threading.RateLimiting": { "type": "Transitive", @@ -1247,19 +1273,19 @@ }, "System.Web.Services.Description": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "6pwntR5vqLOzUPU9GcLVNEASAVf0GFeXoRF4p/SWIiU3073ZbWJ6dJM5cpXgylcbJDjlwPqNx9f5Y4Od0cNfDA==" + "resolved": "8.1.2", + "contentHash": "FziIBleSpygZOBudSeMkawLgfarnSam7paGkTtV9ITyTmw/TdEqB+moS0TeApmNfAMWGbcWXDXr3djckuLgGDg==" }, "System.Windows.Extensions": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "6I+OzjcTx2gtZotjDQXEhWdkfPVxRvT9r9nFWsgt9Of6GwLt9szpIlxx0z2dP3dprg6K3zRU/5bbig+zoVKpfg==" + "resolved": "10.0.3", + "contentHash": "TZ/9e7JLxAT3j66XuNh7Bbqfz1m7UZC92uLknoldmALo82OYhrb4e8LGQ0S3umh2M+JQ94qQpLvPXIkmyuMraw==" }, "werkr.agent": { "type": "Project", "dependencies": { "Grpc.AspNetCore": "[2.76.0, )", - "Microsoft.PowerShell.SDK": "[7.5.4, )", + "Microsoft.PowerShell.SDK": "[7.6.0-rc.1, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.Console": "[6.1.1, )", "Serilog.Sinks.File": "[7.0.0, )", @@ -1482,77 +1508,6 @@ "Microsoft.IdentityModel.Logging": "8.16.0" } }, - "Microsoft.PowerShell.SDK": { - "type": "CentralTransitive", - "requested": "[7.5.4, )", - "resolved": "7.5.4", - "contentHash": "VjRoL4Eja88vOpEflx17ijURIZ3Q5780PTAD8XYhXmlMca6uUghh3qwhpWOQJF8OpYOLUiA6fRPRvoayX2BSXA==", - "dependencies": { - "Microsoft.Bcl.AsyncInterfaces": "8.0.0", - "Microsoft.Extensions.ObjectPool": "8.0.21", - "Microsoft.Management.Infrastructure.CimCmdlets": "7.5.4", - "Microsoft.PowerShell.Commands.Diagnostics": "7.5.4", - "Microsoft.PowerShell.Commands.Management": "7.5.4", - "Microsoft.PowerShell.Commands.Utility": "7.5.4", - "Microsoft.PowerShell.ConsoleHost": "7.5.4", - "Microsoft.PowerShell.Security": "7.5.4", - "Microsoft.WSMan.Management": "7.5.4", - "Microsoft.Win32.Registry.AccessControl": "9.0.10", - "Microsoft.Win32.SystemEvents": "9.0.10", - "Microsoft.Windows.Compatibility": "9.0.10", - "System.CodeDom": "9.0.10", - "System.ComponentModel.Composition": "9.0.10", - "System.ComponentModel.Composition.Registration": "9.0.10", - "System.Configuration.ConfigurationManager": "9.0.10", - "System.Data.Odbc": "9.0.10", - "System.Data.OleDb": "9.0.10", - "System.Data.SqlClient": "4.9.0", - "System.Diagnostics.EventLog": "9.0.10", - "System.Diagnostics.PerformanceCounter": "9.0.10", - "System.DirectoryServices": "9.0.10", - "System.DirectoryServices.AccountManagement": "9.0.10", - "System.DirectoryServices.Protocols": "9.0.10", - "System.Drawing.Common": "9.0.10", - "System.IO.Packaging": "9.0.10", - "System.IO.Ports": "9.0.10", - "System.Management": "9.0.10", - "System.Management.Automation": "7.5.4", - "System.Net.Http.WinHttpHandler": "9.0.10", - "System.Private.ServiceModel": "4.10.3", - "System.Reflection.Context": "9.0.10", - "System.Runtime.Caching": "9.0.10", - "System.Security.Cryptography.Pkcs": "9.0.10", - "System.Security.Cryptography.ProtectedData": "9.0.10", - "System.Security.Cryptography.Xml": "9.0.10", - "System.Security.Permissions": "9.0.10", - "System.ServiceModel.Duplex": "4.10.3", - "System.ServiceModel.Http": "4.10.3", - "System.ServiceModel.NetTcp": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3", - "System.ServiceModel.Security": "4.10.3", - "System.ServiceProcess.ServiceController": "9.0.10", - "System.Speech": "9.0.10", - "System.Web.Services.Description": "8.0.0", - "System.Windows.Extensions": "9.0.10", - "runtime.android-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-x86.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.native.System.IO.Ports": "9.0.10", - "runtime.osx-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.osx-x64.runtime.native.System.IO.Ports": "9.0.10" - } - }, "Npgsql.EntityFrameworkCore.PostgreSQL": { "type": "CentralTransitive", "requested": "[10.0.0, )", diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/AgentConnectionManagerTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/AgentConnectionManagerTests.cs index c08589d..8a87b42 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Communication/AgentConnectionManagerTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/AgentConnectionManagerTests.cs @@ -125,7 +125,7 @@ public async Task GetChannelAsync_RevokedConnection_Throws( ) { _ = await Assert.ThrowsExactlyAsync( async ( ) => await _manager.GetChannelAsync( conn.Id, TestContext.CancellationToken - )); + ) ); } /// @@ -137,7 +137,7 @@ public async Task GetChannelAsync_NonExistentConnection_Throws( ) { _ = await Assert.ThrowsExactlyAsync( async ( ) => await _manager.GetChannelAsync( Guid.NewGuid( ), TestContext.CancellationToken - )); + ) ); } /// diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/KeyRotationTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/KeyRotationTests.cs index b94c791..711bd3a 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Communication/KeyRotationTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/KeyRotationTests.cs @@ -239,7 +239,7 @@ public void GracePeriod_ExpiredPreviousKey_FailsAfterGracePeriod( ) { newKeyId, null, null - )); + ) ); } /// diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/NullEncryptionTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/NullEncryptionTests.cs index ecf495c..ffba83f 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Communication/NullEncryptionTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/NullEncryptionTests.cs @@ -27,7 +27,7 @@ public void EncryptToEnvelope_NullKey_ThrowsArgumentNullException( ) { message, null!, "key-1" - )); + ) ); } /// @@ -47,7 +47,7 @@ public void DecryptFromEnvelope_NullKey_ThrowsArgumentNullException( ) { _ = Assert.ThrowsExactly( ( ) => PayloadEncryptor.DecryptFromEnvelope( envelope, null! - )); + ) ); } /// @@ -70,7 +70,7 @@ public void DecryptFromEnvelope_KeyRotation_NullCurrentKey_ThrowsArgumentNullExc "key-2", validKey, "key-1" - )); + ) ); } /// diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/PayloadEncryptorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/PayloadEncryptorTests.cs index fd91e49..65c10df 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Communication/PayloadEncryptorTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/PayloadEncryptorTests.cs @@ -79,10 +79,12 @@ public void EncryptDecryptEnvelope_EmptyMessage_RoundTrip( ) { /// [TestMethod] public void EncryptDecryptEnvelope_LargePayload_RoundTrip( ) { - HeartbeatRequest original = new( ) { StatusMessage = new string( + HeartbeatRequest original = new( ) { + StatusMessage = new string( 'A', 100_000 - )}; + ) + }; EncryptedEnvelope envelope = PayloadEncryptor.EncryptToEnvelope( original, @@ -163,7 +165,7 @@ public void DecryptFromEnvelope_WrongKey_Throws( ) { _ = Assert.ThrowsExactly( ( ) => PayloadEncryptor.DecryptFromEnvelope( envelope, wrongKey - )); + ) ); } /// @@ -192,7 +194,7 @@ public void DecryptFromEnvelope_TamperedCiphertext_Throws( ) { _ = Assert.ThrowsExactly( ( ) => PayloadEncryptor.DecryptFromEnvelope( tampered, _sharedKey - )); + ) ); } /// @@ -221,7 +223,7 @@ public void DecryptFromEnvelope_TamperedIv_Throws( ) { _ = Assert.ThrowsExactly( ( ) => PayloadEncryptor.DecryptFromEnvelope( tampered, _sharedKey - )); + ) ); } /// @@ -250,7 +252,7 @@ public void DecryptFromEnvelope_TamperedAuthTag_Throws( ) { _ = Assert.ThrowsExactly( ( ) => PayloadEncryptor.DecryptFromEnvelope( tampered, _sharedKey - )); + ) ); } /// diff --git a/src/Test/Werkr.Tests.Data/Unit/Cryptography/EncryptionProviderTests.cs b/src/Test/Werkr.Tests.Data/Unit/Cryptography/EncryptionProviderTests.cs index b026e67..d617ff8 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Cryptography/EncryptionProviderTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Cryptography/EncryptionProviderTests.cs @@ -109,7 +109,7 @@ public void RSADecrypt_WrongKey_ThrowsWerkrCryptoException( ) { _ = Assert.ThrowsExactly( ( ) => EncryptionProvider.RSADecrypt( ciphertext, keyPair2.PrivateKey - )); + ) ); } // -- AES-256-GCM -- @@ -162,7 +162,7 @@ out byte[] tag key2, nonce, tag - )); + ) ); } // -- Password-based AES-GCM -- @@ -205,7 +205,7 @@ public void AesGcmPasswordDecrypt_WrongPassword_ThrowsWerkrCryptoException( ) { _ = Assert.ThrowsExactly( ( ) => EncryptionProvider.AesGcmPasswordDecrypt( encrypted, "WrongPassword" - )); + ) ); } // -- Sign / Verify -- diff --git a/src/Test/Werkr.Tests.Data/Unit/Cryptography/HybridEncryptionTests.cs b/src/Test/Werkr.Tests.Data/Unit/Cryptography/HybridEncryptionTests.cs index 85761d4..59b5706 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Cryptography/HybridEncryptionTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Cryptography/HybridEncryptionTests.cs @@ -86,7 +86,7 @@ public void HybridDecrypt_WrongKey_ThrowsWerkrCryptoException( ) { _ = Assert.ThrowsExactly( ( ) => EncryptionProvider.HybridDecrypt( encrypted, wrongKeyPair.PrivateKey - )); + ) ); } /// @@ -110,6 +110,6 @@ public void HybridDecrypt_TamperedEnvelope_ThrowsWerkrCryptoException( ) { _ = Assert.ThrowsExactly( ( ) => EncryptionProvider.HybridDecrypt( encrypted, s_keyPair.PrivateKey - )); + ) ); } } diff --git a/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationBundlePayloadTests.cs b/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationBundlePayloadTests.cs index 0a2bc3a..a9fd322 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationBundlePayloadTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationBundlePayloadTests.cs @@ -69,7 +69,7 @@ public void FromEncryptedString_WrongPassword_ThrowsWerkrCryptoException( ) { _ = Assert.ThrowsExactly( ( ) => RegistrationBundlePayload.FromEncryptedString( encrypted, "WrongPassword" - )); + ) ); } /// @@ -83,7 +83,7 @@ public void FromEncryptedString_CorruptedData_ThrowsWerkrCryptoException( ) { _ = Assert.ThrowsExactly( ( ) => RegistrationBundlePayload.FromEncryptedString( corrupted, "password" - )); + ) ); } /// @@ -94,6 +94,6 @@ public void FromEncryptedString_EmptyString_ThrowsArgumentException( ) { _ = Assert.ThrowsExactly( ( ) => RegistrationBundlePayload.FromEncryptedString( "", "password" - )); + ) ); } } diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleCalculatorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleCalculatorTests.cs index 9a4e37b..01cff0a 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleCalculatorTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleCalculatorTests.cs @@ -270,10 +270,13 @@ public class ScheduleCalculatorTests { /// [TestMethod] public void CalculateOccurrences_UtcDt_ReturnsSingleOccurrence( ) { - Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUTC, UtcTz - )}; + ) + }; IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow @@ -294,10 +297,13 @@ public void CalculateOccurrences_UtcDt_ReturnsSingleOccurrence( ) { /// [TestMethod] public void CalculateOccurrences_LocalDt_ReturnsSingleOccurrence( ) { - Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartLocal, LocalTz - )}; + ) + }; IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow @@ -318,10 +324,13 @@ public void CalculateOccurrences_LocalDt_ReturnsSingleOccurrence( ) { /// [TestMethod] public void CalculateOccurrences_UnspecDt_ReturnsSingleOccurrence( ) { - Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartUnspecified, DatelineTz - )}; + ) + }; IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow @@ -341,10 +350,13 @@ public void CalculateOccurrences_UnspecDt_ReturnsSingleOccurrence( ) { /// [TestMethod] public void CalculateOccurrences_P14Dt_ReturnsSingleOccurrence( ) { - Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus14, LineIslandsTz - )}; + ) + }; IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow @@ -364,10 +376,13 @@ public void CalculateOccurrences_P14Dt_ReturnsSingleOccurrence( ) { /// [TestMethod] public void CalculateOccurrences_P13Dt_ReturnsSingleOccurrence( ) { - Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus13, SamoaTz - )}; + ) + }; IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow @@ -387,10 +402,13 @@ public void CalculateOccurrences_P13Dt_ReturnsSingleOccurrence( ) { /// [TestMethod] public void CalculateOccurrences_P1245Dt_ReturnsSingleOccurrence( ) { - Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartPlus1245, ChathamIslandsTz - )}; + ) + }; IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow @@ -410,10 +428,13 @@ public void CalculateOccurrences_P1245Dt_ReturnsSingleOccurrence( ) { /// [TestMethod] public void CalculateOccurrences_M330Dt_ReturnsSingleOccurrence( ) { - Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( StartMinus330, NewfoundlandTz - )}; + ) + }; IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow @@ -433,10 +454,13 @@ public void CalculateOccurrences_M330Dt_ReturnsSingleOccurrence( ) { /// [TestMethod] public void CalculateOccurrences_EndOfWindow_ReturnsEmptyEnumerable( ) { - Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( EndOfWindow, UtcTz - )}; + ) + }; IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow @@ -452,10 +476,13 @@ public void CalculateOccurrences_EndOfWindow_ReturnsEmptyEnumerable( ) { /// [TestMethod] public void CalculateOccurrences_AfterEndOfWindow_ReturnsEmptyEnumerable( ) { - Schedule schedule = new() { DbSchedule = TestDb(), StartDateTime = MakeStart( + Schedule schedule = new() { + DbSchedule = TestDb(), + StartDateTime = MakeStart( EndOfWindow.AddDays( 1 ), UtcTz - )}; + ) + }; IReadOnlyList occurrences = ScheduleCalculator.CalculateOccurrences( schedule, EndOfWindow @@ -2588,7 +2615,7 @@ public static void CalculateOccurrences_SimpleWeeklyRecurrence( DateTime endOfWindow, IReadOnlyList occurrences, int validationCount - ){ + ) { if (schedule?.WeeklyRecurrence == null) { throw new ArgumentNullException( nameof( schedule ), diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleServiceTests.cs index dcb5d73..f9f2dc3 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleServiceTests.cs @@ -319,7 +319,7 @@ public async Task CreateAsync_NoStartDateTime_ThrowsValidationException( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.CreateAsync( schedule, TestContext.CancellationToken - )); + ) ); } /// @@ -337,7 +337,7 @@ public async Task CreateAsync_MultipleRecurrenceTypes_ThrowsValidationException( _ = await Assert.ThrowsExactlyAsync( ( ) => _service.CreateAsync( schedule, TestContext.CancellationToken - )); + ) ); } #endregion CreateAsync @@ -622,7 +622,7 @@ public async Task UpdateAsync_NonExistentId_ThrowsKeyNotFoundException( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.UpdateAsync( schedule, TestContext.CancellationToken - )); + ) ); } #endregion UpdateAsync @@ -649,7 +649,7 @@ await _service.DeleteAsync( Assert.IsNull( await _service.GetByIdAsync( id, TestContext.CancellationToken - )); + ) ); Assert.HasCount( 0, await _dbContext.Schedules.ToListAsync( TestContext.CancellationToken ) @@ -681,7 +681,7 @@ public async Task DeleteAsync_NonExistentId_ThrowsKeyNotFoundException( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.DeleteAsync( Guid.NewGuid( ), TestContext.CancellationToken - )); + ) ); } #endregion DeleteAsync @@ -727,7 +727,7 @@ public async Task PreviewOccurrencesAsync_NonExistentId_ThrowsKeyNotFoundExcepti Guid.NewGuid( ), DateTime.UtcNow.AddDays( 30 ), TestContext.CancellationToken - )); + ) ); } #endregion PreviewOccurrencesAsync @@ -800,7 +800,7 @@ await _service.DeleteAsync( Assert.IsNull( await _service.GetByIdAsync( id, TestContext.CancellationToken - )); + ) ); } #endregion RoundTrip diff --git a/src/Test/Werkr.Tests.Data/Unit/Tasks/AgentResolverTests.cs b/src/Test/Werkr.Tests.Data/Unit/Tasks/AgentResolverTests.cs index 3782d3a..93f50b5 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Tasks/AgentResolverTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Tasks/AgentResolverTests.cs @@ -100,7 +100,7 @@ private RegisteredConnection MakeConnection( string name, ConnectionStatus status, params string[] tags - ){ + ) { return new RegisteredConnection { ConnectionName = name, RemoteUrl = $"https://{name}.test:5100", @@ -146,7 +146,7 @@ public async Task Resolve_ReturnsNull_WhenNoTagMatch( ) { "agent1", ConnectionStatus.Connected, "windows" - )); + ) ); _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); RegisteredConnection? result = await _resolver.ResolveAsync( @@ -166,7 +166,7 @@ public async Task Resolve_ReturnsMatch_WhenTagsIntersect( ) { ConnectionStatus.Connected, "linux", "docker" - )); + ) ); _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); RegisteredConnection? result = await _resolver.ResolveAsync( @@ -189,7 +189,7 @@ public async Task Resolve_CaseInsensitive( ) { "agent1", ConnectionStatus.Connected, "Linux" - )); + ) ); _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); RegisteredConnection? result = await _resolver.ResolveAsync( @@ -208,12 +208,12 @@ public async Task Resolve_IgnoresDisconnectedAgents( ) { "disconnected", ConnectionStatus.Disconnected, "linux" - )); + ) ); _ = _dbContext.RegisteredConnections.Add( MakeConnection( "revoked", ConnectionStatus.Revoked, "linux" - )); + ) ); _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); RegisteredConnection? result = await _resolver.ResolveAsync( @@ -232,18 +232,18 @@ public async Task ResolveAll_ReturnsMultipleMatches( ) { "agent1", ConnectionStatus.Connected, "linux" - )); + ) ); _ = _dbContext.RegisteredConnections.Add( MakeConnection( "agent2", ConnectionStatus.Connected, "linux", "docker" - )); + ) ); _ = _dbContext.RegisteredConnections.Add( MakeConnection( "agent3", ConnectionStatus.Connected, "windows" - )); + ) ); _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); IReadOnlyList results = await _resolver.ResolveAllAsync( @@ -265,7 +265,7 @@ public async Task ResolveAll_EmptyTags_ReturnsEmpty( ) { "agent1", ConnectionStatus.Connected, "linux" - )); + ) ); _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); IReadOnlyList results = await _resolver.ResolveAllAsync( diff --git a/src/Test/Werkr.Tests.Data/Unit/Tasks/TaskServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Tasks/TaskServiceTests.cs index 5cd15ce..a4e1d5c 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Tasks/TaskServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Tasks/TaskServiceTests.cs @@ -126,7 +126,7 @@ public async Task Create_RequiresName( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.CreateAsync( task, TestContext.CancellationToken - )); + ) ); } /// @@ -140,7 +140,7 @@ public async Task Create_RequiresContent( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.CreateAsync( task, TestContext.CancellationToken - )); + ) ); } /// @@ -154,7 +154,7 @@ public async Task Create_RequiresTargetTags( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.CreateAsync( task, TestContext.CancellationToken - )); + ) ); } /// @@ -169,7 +169,7 @@ public async Task Create_RejectsInvalidActionType( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.CreateAsync( task, TestContext.CancellationToken - )); + ) ); } /// @@ -183,7 +183,7 @@ public async Task Create_RejectsNegativeTimeout( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.CreateAsync( task, TestContext.CancellationToken - )); + ) ); } // ── GetAll ── @@ -289,7 +289,7 @@ public async Task Update_ThrowsKeyNotFound_WhenMissing( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.UpdateAsync( update, TestContext.CancellationToken - )); + ) ); } // ── Delete ── @@ -323,7 +323,7 @@ public async Task Delete_ThrowsKeyNotFound_WhenMissing( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.DeleteAsync( 999, TestContext.CancellationToken - )); + ) ); } // ── SetEnabled ── @@ -363,6 +363,6 @@ public async Task SetEnabled_ThrowsKeyNotFound_WhenMissing( ) { 999, false, TestContext.CancellationToken - )); + ) ); } } diff --git a/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowExecutorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowExecutorTests.cs index ef443ad..0d17eae 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowExecutorTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowExecutorTests.cs @@ -92,10 +92,13 @@ public void TestInit( ) { ); ConditionEvaluator conditionEvaluator = new( NullLogger.Instance ); - IOptions outputOptions = Options.Create( new JobOutputOptions { OutputDirectory = Path.Combine( + IOptions outputOptions = Options.Create( new JobOutputOptions { + OutputDirectory = Path.Combine( Path.GetTempPath( ), $"werkr_test_{Guid.NewGuid( ):N}" - ), TailPreviewLength = 500, } ); + ), + TailPreviewLength = 500, + } ); JobOutputWriter outputWriter = new( outputOptions, NullLogger.Instance @@ -1270,7 +1273,7 @@ private async Task SeedAgentAsync( string name, string[] tags, CancellationToken ct - ){ + ) { RegisteredConnection agent = new( ) { Id = Guid.NewGuid( ), ConnectionName = name, @@ -1301,7 +1304,7 @@ private async Task SeedTaskAsync( string[] targetTags, string? successCriteria, CancellationToken ct - ){ + ) { WerkrTask task = new( ) { Name = name, Description = "Test", @@ -1339,7 +1342,7 @@ CancellationToken ct workflow.Id, ct ))!; - return( + return ( workflow, step ); @@ -1448,7 +1451,7 @@ await _workflowService.AddStepDependencyAsync( private async Task SeedIfWorkflowAsync( string condition, CancellationToken ct - ){ + ) { _ = await SeedAgentAsync( ct ); WerkrTask task = await SeedTaskAsync( ct ); @@ -1506,7 +1509,7 @@ public async IAsyncEnumerable ExecuteCommandAsync( OperatorType operatorType, string command, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default - ){ + ) { InvokedAgentIds.Add( agentConnectionId ); int exitCode = AgentExitCodes.GetValueOrDefault( agentConnectionId, @@ -1533,7 +1536,7 @@ public async IAsyncEnumerable ExecuteScriptAsync( string scriptPath, IEnumerable? args, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default - ){ + ) { InvokedAgentIds.Add( agentConnectionId ); int exitCode = AgentExitCodes.GetValueOrDefault( agentConnectionId, @@ -1558,7 +1561,7 @@ public async IAsyncEnumerable ExecuteActionAsync( Guid agentConnectionId, ActionDescriptor descriptor, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default - ){ + ) { InvokedAgentIds.Add( agentConnectionId ); int exitCode = AgentExitCodes.GetValueOrDefault( agentConnectionId, diff --git a/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowServiceTests.cs index d3e0e69..51e9fae 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowServiceTests.cs @@ -104,7 +104,7 @@ public async Task CreateAsync_DuplicateName_Throws( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.CreateAsync( w2, ct - )); + ) ); } /// @@ -197,7 +197,7 @@ public async Task UpdateAsync_NotFound_Throws( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.UpdateAsync( update, ct - )); + ) ); } /// @@ -233,7 +233,7 @@ public async Task DeleteAsync_NotFound_Throws( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.DeleteAsync( 999, ct - )); + ) ); } /// @@ -305,7 +305,7 @@ public async Task AddStepAsync_WorkflowNotFound_Throws( ) { 999, step, ct - )); + ) ); } /// @@ -424,7 +424,7 @@ public async Task AddStepDependencyAsync_SelfReference_Throws( ) { 1, 1, ct - )); + ) ); } /// @@ -444,7 +444,7 @@ await _service.AddStepDependencyAsync( s2.Id, s1.Id, ct - )); + ) ); } /// @@ -530,7 +530,7 @@ await _service.AddStepDependencyAsync( _ = await Assert.ThrowsExactlyAsync( ( ) => _service.ValidateDagAsync( s1.WorkflowId, ct - )); + ) ); } /// @@ -562,7 +562,7 @@ public async Task ValidateDagAsync_WorkflowNotFound_Throws( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.ValidateDagAsync( 999, ct - )); + ) ); } /// @@ -650,7 +650,7 @@ public async Task ValidateDagAsync_IfWithoutCondition_Throws( ) { _ = await Assert.ThrowsExactlyAsync( ( ) => _service.ValidateDagAsync( workflow.Id, ct - )); + ) ); } // ── Helpers ── @@ -687,7 +687,7 @@ private async Task SeedTaskAsync( CancellationToken ct ) { ct ); - return( + return ( s1, s2 ); diff --git a/src/Werkr.Agent/Program.cs b/src/Werkr.Agent/Program.cs index 4f5dae9..96397b6 100644 --- a/src/Werkr.Agent/Program.cs +++ b/src/Werkr.Agent/Program.cs @@ -25,6 +25,7 @@ namespace Werkr.Agent; /// Application entry point for the Werkr Agent. public class Program { + private static readonly Random _random = new(); /// Main entry point. /// Command-line arguments. @@ -163,16 +164,58 @@ public static async Task Main( string[] args ) { // Registration endpoints (localhost-only) _ = app.MapRegistrationEndpoints( ); - _ = app.MapGet( "/", ( ) => "Werkr Agent is running. Communication is via gRPC." ); + _ = app.MapGet( "/", GetAgentArt ); - app.Run( ); + await app.RunAsync( ); } catch (Exception ex) { Log.Fatal( ex, "Werkr Agent terminated unexpectedly." ); } finally { - Log.CloseAndFlush( ); + await Log.CloseAndFlushAsync( ); } } + private static IResult GetAgentArt( ) { + const string asciiArt = """ + ╔════════════════════════════════╗ + ║ ┌────────────────────────────┐ ║ + ║ │ --- --- │ ║ + ║ │ • • │ ║ +╔═══║ │ ______________________ │ ║═══╗ +║ ║ └────────────────────────────┘ ║ ║ +║ ║ __ __ _ ║ ║ +║ ║ \ \ / /__ _ __| | ___ __ ║ ║ +║ ║ \ \ /\ / / _ \ '__| |/ / '__| ║ ║ +║___║ \ V V / __/ | | <| | ║___║ + ║ \_/\_/ \___|_| |_|\_\_| ║ + ╚════════════════════════════════╝ + |AGENT| | gRPC| + ++++++++++++++++++++++++++++++++++ +"""; + + const string happyAsciiArt = """ + ╔════════════════════════════════╗ + ║ ┌────────────────────────────┐ ║ + ║ │ --- --- │ ║ + ║ │ • • │ ║ ,--, + ╔════║ │ \____________________/ │ ║════╗ \ / + ║ ║ └────────────────────────────┘ ║ ║ {|||)< + ║ ║ __ __ _ ║ ║ / \ + ║ ║ \ \ / /__ _ __| | ___ __ ║ ║ `--` + ║ ║ \ \ /\ / / _ \ '__| |/ / '__| ║ ║ + ║____║ \ V V / __/ | | <| | ║____║ \|/ \|/ \|/ \|/ \|/ \|/ \|/ + ║ \_/\_/ \___|_| |_|\_\_| ║ --*-- --*-- --*-- --*-- --*-- --*-- --*-- + ╚════════════════════════════════╝ | | | | | | | + |AGENT| | gRPC| | | | | | | | +++++++++++++++++++++++++++++++++++++++++._______._|_.__._|_.__._|_.__._|_.__._|_.__._|_.__._|_. +"""; + return Results.Text( + content: _random.Next( 0, 10 ) == 0 + ? happyAsciiArt + : asciiArt, + contentType: "text/plain; charset=utf-8" + ); + } + /// /// Returns the platform-appropriate data directory for the Agent. /// Checks the WERKR_DATA_DIR environment variable first, then falls back diff --git a/src/Werkr.Agent/packages.lock.json b/src/Werkr.Agent/packages.lock.json index ea7f4a2..2e0d009 100644 --- a/src/Werkr.Agent/packages.lock.json +++ b/src/Werkr.Agent/packages.lock.json @@ -21,76 +21,50 @@ }, "Microsoft.PowerShell.SDK": { "type": "Direct", - "requested": "[7.5.4, )", - "resolved": "7.5.4", - "contentHash": "VjRoL4Eja88vOpEflx17ijURIZ3Q5780PTAD8XYhXmlMca6uUghh3qwhpWOQJF8OpYOLUiA6fRPRvoayX2BSXA==", - "dependencies": { - "Microsoft.Bcl.AsyncInterfaces": "8.0.0", - "Microsoft.Extensions.ObjectPool": "8.0.21", - "Microsoft.Management.Infrastructure.CimCmdlets": "7.5.4", - "Microsoft.PowerShell.Commands.Diagnostics": "7.5.4", - "Microsoft.PowerShell.Commands.Management": "7.5.4", - "Microsoft.PowerShell.Commands.Utility": "7.5.4", - "Microsoft.PowerShell.ConsoleHost": "7.5.4", - "Microsoft.PowerShell.Security": "7.5.4", - "Microsoft.WSMan.Management": "7.5.4", - "Microsoft.Win32.Registry.AccessControl": "9.0.10", - "Microsoft.Win32.SystemEvents": "9.0.10", - "Microsoft.Windows.Compatibility": "9.0.10", - "System.CodeDom": "9.0.10", - "System.ComponentModel.Composition": "9.0.10", - "System.ComponentModel.Composition.Registration": "9.0.10", - "System.Configuration.ConfigurationManager": "9.0.10", - "System.Data.Odbc": "9.0.10", - "System.Data.OleDb": "9.0.10", + "requested": "[7.6.0-rc.1, )", + "resolved": "7.6.0-rc.1", + "contentHash": "0AAObi5+pcXKD+4CACnn30WXhe0cP9+5VcZeopAEpKTQRhPrBtYiS2ACMTy72oHJGojCcaX6n+7WW2xuTEd8dg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.3", + "Microsoft.Management.Infrastructure.CimCmdlets": "7.6.0-rc.1", + "Microsoft.PowerShell.Commands.Diagnostics": "7.6.0-rc.1", + "Microsoft.PowerShell.Commands.Management": "7.6.0-rc.1", + "Microsoft.PowerShell.Commands.Utility": "7.6.0-rc.1", + "Microsoft.PowerShell.ConsoleHost": "7.6.0-rc.1", + "Microsoft.PowerShell.Security": "7.6.0-rc.1", + "Microsoft.WSMan.Management": "7.6.0-rc.1", + "Microsoft.Win32.Registry.AccessControl": "10.0.3", + "Microsoft.Win32.SystemEvents": "10.0.3", + "Microsoft.Windows.Compatibility": "10.0.3", + "System.CodeDom": "10.0.3", + "System.ComponentModel.Composition": "10.0.3", + "System.ComponentModel.Composition.Registration": "10.0.3", + "System.Configuration.ConfigurationManager": "10.0.3", + "System.Data.Odbc": "10.0.3", + "System.Data.OleDb": "10.0.3", "System.Data.SqlClient": "4.9.0", - "System.Diagnostics.EventLog": "9.0.10", - "System.Diagnostics.PerformanceCounter": "9.0.10", - "System.DirectoryServices": "9.0.10", - "System.DirectoryServices.AccountManagement": "9.0.10", - "System.DirectoryServices.Protocols": "9.0.10", - "System.Drawing.Common": "9.0.10", - "System.IO.Packaging": "9.0.10", - "System.IO.Ports": "9.0.10", - "System.Management": "9.0.10", - "System.Management.Automation": "7.5.4", - "System.Net.Http.WinHttpHandler": "9.0.10", - "System.Private.ServiceModel": "4.10.3", - "System.Reflection.Context": "9.0.10", - "System.Runtime.Caching": "9.0.10", - "System.Security.Cryptography.Pkcs": "9.0.10", - "System.Security.Cryptography.ProtectedData": "9.0.10", - "System.Security.Cryptography.Xml": "9.0.10", - "System.Security.Permissions": "9.0.10", - "System.ServiceModel.Duplex": "4.10.3", - "System.ServiceModel.Http": "4.10.3", - "System.ServiceModel.NetTcp": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3", - "System.ServiceModel.Security": "4.10.3", - "System.ServiceProcess.ServiceController": "9.0.10", - "System.Speech": "9.0.10", - "System.Text.Encoding.CodePages": "9.0.10", - "System.Text.Encodings.Web": "9.0.10", - "System.Threading.AccessControl": "9.0.10", - "System.Web.Services.Description": "8.0.0", - "System.Windows.Extensions": "9.0.10", - "runtime.android-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-x86.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.native.System.IO.Ports": "9.0.10", - "runtime.osx-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.osx-x64.runtime.native.System.IO.Ports": "9.0.10" + "System.Diagnostics.PerformanceCounter": "10.0.3", + "System.DirectoryServices": "10.0.3", + "System.DirectoryServices.AccountManagement": "10.0.3", + "System.DirectoryServices.Protocols": "10.0.3", + "System.Drawing.Common": "10.0.3", + "System.IO.Packaging": "10.0.3", + "System.IO.Ports": "10.0.3", + "System.Management": "10.0.3", + "System.Management.Automation": "7.6.0-rc.1", + "System.Net.Http.WinHttpHandler": "10.0.3", + "System.Reflection.Context": "10.0.3", + "System.Runtime.Caching": "10.0.3", + "System.Security.Cryptography.Pkcs": "10.0.3", + "System.Security.Cryptography.ProtectedData": "10.0.3", + "System.Security.Permissions": "10.0.3", + "System.ServiceModel.Http": "10.0.652802", + "System.ServiceModel.NetFramingBase": "10.0.652802", + "System.ServiceModel.NetTcp": "10.0.652802", + "System.ServiceModel.Primitives": "10.0.652802", + "System.ServiceModel.Syndication": "10.0.3", + "System.ServiceProcess.ServiceController": "10.0.3", + "System.Speech": "10.0.3" } }, "Serilog.AspNetCore": { @@ -174,48 +148,40 @@ }, "Json.More.Net": { "type": "Transitive", - "resolved": "2.0.2", - "contentHash": "izscdjjk8EAHDBCjyz7V7n77SzkrSjh/hUGV6cyR6PlVdjYDh5ohc8yqvwSqJ9+6Uof8W6B24dIHlDKD+I1F8A==" + "resolved": "2.1.1", + "contentHash": "ZXAKl2VsdnIZeUo1PFII3Oi1m1L4YQjEyDjygHfHln5vgsjgIo749X6xWkv7qFYp8RROES+vOEfDcvvoVgs8kA==" }, "JsonPointer.Net": { "type": "Transitive", - "resolved": "5.0.2", - "contentHash": "H/OtixKadr+ja1j7Fru3WG56V9zP0AKT1Bd0O7RWN/zH1bl8ZIwW9aCa4+xvzuVvt4SPmrvBu3G6NpAkNOwNAA==", + "resolved": "5.3.1", + "contentHash": "3e2OJjU0OaE26XC/klgxbJuXvteFWTDJIJv0ITYWcJEoskq7jzUwPSC1s0iz4wPPQnfN7vwwFmg2gJfwRAPwgw==", "dependencies": { "Humanizer.Core": "2.14.1", - "Json.More.Net": "2.0.1.2" + "Json.More.Net": "2.1.1" } }, "JsonSchema.Net": { "type": "Transitive", - "resolved": "7.2.3", - "contentHash": "O3KclMcPVFYTZsTeZBpwtKd/lYrNc3AFR+xi9j3Q4CfhDufOUx25TMMWJOcFRrqVklvKQ4Kl+0UhlNX1iDGoRw==", + "resolved": "7.4.0", + "contentHash": "5T3DWENwuCzLwFWz0qjXXVWA8+5+gC95OLkhqUBWpVpWBMr9gwfhWNeX8rWyr+fLQ7pIQ+lWuHIrmXRudxOOSw==", "dependencies": { - "JsonPointer.Net": "5.0.0" + "JsonPointer.Net": "5.3.1" } }, "Markdig.Signed": { "type": "Transitive", - "resolved": "0.38.0", - "contentHash": "zfi6kNm5QJnsCGm5a0hMG2qw8juYbOfsS4c1OuTcqkbYQUCdkam6d6Nt7nPIrbV4D+U7sHChidSQlg+ViiMPuw==" + "resolved": "0.44.0", + "contentHash": "mNxf8HrQA/clO8usqQhVc0BGlw0bJtZ76dic5KZGBPJZDX4UR67Jglwilkp5A//gPSMwcoY5EjLPppkZ/B4IMg==" }, "Microsoft.ApplicationInsights": { "type": "Transitive", - "resolved": "2.22.0", - "contentHash": "3AOM9bZtku7RQwHyMEY3tQMrHIgjcfRDa6YQpd/QG2LDGvMydSlL9Di+8LLMt7J2RDdfJ7/2jdYv6yHcMJAnNw==", - "dependencies": { - "System.Diagnostics.DiagnosticSource": "5.0.0" - } - }, - "Microsoft.AspNetCore.Metadata": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + "resolved": "10.0.3", + "contentHash": "TV62UsrJZPX6gbt3c4WrtXh7bmaDIcMqf9uft1cc4L6gJXOU07hDGEh+bFQh/L2Az0R1WVOkiT66lFqS6G2NmA==" }, "Microsoft.CodeAnalysis.Analyzers": { "type": "Transitive", @@ -224,23 +190,19 @@ }, "Microsoft.CodeAnalysis.Common": { "type": "Transitive", - "resolved": "4.11.0", - "contentHash": "djf8ujmqYImFgB04UGtcsEhHrzVqzHowS+EEl/Yunc5LdrYrZhGBWUTXoCF0NzYXJxtfuD+UVQarWpvrNc94Qg==", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.3.4", - "System.Collections.Immutable": "8.0.0", - "System.Reflection.Metadata": "8.0.0" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" } }, "Microsoft.CodeAnalysis.CSharp": { "type": "Transitive", - "resolved": "4.11.0", - "contentHash": "6XYi2EusI8JT4y2l/F3VVVS+ISoIX9nqHsZRaG6W5aFeJ5BEuBosHfT/ABb73FN0RZ1Z3cj2j7cL28SToJPXOw==", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "3.3.4", - "Microsoft.CodeAnalysis.Common": "[4.11.0]", - "System.Collections.Immutable": "8.0.0", - "System.Reflection.Metadata": "8.0.0" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" } }, "Microsoft.EntityFrameworkCore.Abstractions": { @@ -258,10 +220,7 @@ "resolved": "10.0.3", "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" + "Microsoft.EntityFrameworkCore": "10.0.3" } }, "Microsoft.EntityFrameworkCore.Sqlite.Core": { @@ -271,249 +230,49 @@ "dependencies": { "Microsoft.Data.Sqlite.Core": "10.0.3", "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", "SQLitePCLRaw.core": "2.1.11" } }, "Microsoft.Extensions.AmbientMetadata.Application": { "type": "Transitive", "resolved": "10.3.0", - "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" - } - }, - "Microsoft.Extensions.Caching.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Caching.Memory": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==" }, "Microsoft.Extensions.Compliance.Abstractions": { "type": "Transitive", "resolved": "10.3.0", - "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3" - } - }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Configuration.Binder": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" - } - }, - "Microsoft.Extensions.Configuration.FileExtensions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Physical": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" - } - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==" }, "Microsoft.Extensions.DependencyInjection.AutoActivation": { "type": "Transitive", "resolved": "10.3.0", - "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==", - "dependencies": { - "Microsoft.Extensions.Hosting.Abstractions": "10.0.3" - } + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", "resolved": "10.0.3", "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" }, - "Microsoft.Extensions.Diagnostics": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } - }, "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { "type": "Transitive", "resolved": "10.3.0", - "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" - } - }, - "Microsoft.Extensions.Features": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "djFt1Jt+2uREWWVQiiA4ilYBDtHHY7nK08c5K8xBD9+XFNw3KDVprylrMkH08bZGK3ZHRAkS7JDV9srfLrcm/g==" - }, - "Microsoft.Extensions.FileProviders.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.FileProviders.Physical": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.FileSystemGlobbing": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" - }, - "Microsoft.Extensions.Http": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "M5gWob3dtzlF14oto1lR1ZuSJrR0gGc+obv7zY9LGmX5y3Ndpve29MrrjqJW/m4CFud4TE/KFUuHjjtwxhCO8g==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==" }, "Microsoft.Extensions.Http.Diagnostics": { "type": "Transitive", "resolved": "10.3.0", "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", "dependencies": { - "Microsoft.Extensions.Http": "10.0.3", "Microsoft.Extensions.Telemetry": "10.3.0" } }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } - }, - "Microsoft.Extensions.Logging.Configuration": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "PBlaoYeusaxNYyN4WFjzcXWlUDSvLUPxy/e6oP1SONOOYA/oBWT2uBmFGJMV9VTtXiXXxCB39LqlYWbsWE4UKA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" - } - }, - "Microsoft.Extensions.ObjectPool": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "dQKlVXzqflsv5X8iDlAN5YmTL1GcLCrOLKo1s9PNdfjqxeu0S/jmWTfiLGno+8+o1qFL3+VFAH5/ftmypN+sPw==" - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" - }, "Microsoft.Extensions.Resilience": { "type": "Transitive", "resolved": "10.3.0", "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", "dependencies": { - "Microsoft.Extensions.Diagnostics": "10.0.3", "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3", "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", "Polly.Extensions": "8.4.2", "Polly.RateLimiting": "8.4.2" @@ -522,16 +281,7 @@ "Microsoft.Extensions.ServiceDiscovery.Abstractions": { "type": "Transitive", "resolved": "10.3.0", - "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Features": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==" }, "Microsoft.Extensions.Telemetry": { "type": "Transitive", @@ -540,8 +290,6 @@ "dependencies": { "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", - "Microsoft.Extensions.Logging.Configuration": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3", "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" } }, @@ -550,10 +298,7 @@ "resolved": "10.3.0", "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", "dependencies": { - "Microsoft.Extensions.Compliance.Abstractions": "10.3.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0" } }, "Microsoft.IdentityModel.Abstractions": { @@ -580,10 +325,10 @@ }, "Microsoft.Management.Infrastructure.CimCmdlets": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "p2nh2bDZGeAsOLd/QwRrZGahPV1Jy1Z0LNA/ZSqpyN8Cp31qh1UOfpmq4rss5P5deuygAN6DTLn96LY5oEDQpg==", + "resolved": "7.6.0-rc.1", + "contentHash": "+iB/Rj2xnjHo//4E+ADeQMc8LcWy8/XNRp3HqmIAeB/vPcCeEMdY3zald6nlJ90wQ9iZFRIpBKX3MM0IDMU6kg==", "dependencies": { - "System.Management.Automation": "7.5.4" + "System.Management.Automation": "7.6.0-rc.1" } }, "Microsoft.Management.Infrastructure.Runtime.Unix": { @@ -598,56 +343,47 @@ }, "Microsoft.PowerShell.Commands.Diagnostics": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "sRBHmXm2Ivy6pyAI2OX5PJ1DXbmmA1/OusFEXwdWWEjjiZ0prul3POc3GJoiMSn6WF5dJ6xw53MKZrkvu4uCgA==", + "resolved": "7.6.0-rc.1", + "contentHash": "gyy6F2m0USZOLJKZrSfagZzU6gwgHY0Ttz8UqY1U3VM3KDo7hE9fq1XDyDJ/5nxUkMjKv76x2YGZoyFCqKmaww==", "dependencies": { - "System.Management.Automation": "7.5.4" + "System.Management.Automation": "7.6.0-rc.1" } }, "Microsoft.PowerShell.Commands.Management": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "OhkLYDIf2xeexTWi+3yBRIrGMCpBBDPGAzKAp0wLCj3IE1D2H1Uj4XEE67y69eLFx7jxVwy2Er9hoTt5joECig==", + "resolved": "7.6.0-rc.1", + "contentHash": "CA3FbDTpKaXWFxTqRFSD6IblV7WWQ/8ru7+xYlOpuMX6F4L7mwYkVXtytLcYLeWwpiOX5Jo8L8TdENlS3PGeVA==", "dependencies": { - "Microsoft.PowerShell.Security": "7.5.4", - "System.Diagnostics.EventLog": "9.0.10", - "System.ServiceProcess.ServiceController": "9.0.10" + "Microsoft.PowerShell.Security": "7.6.0-rc.1", + "System.ServiceProcess.ServiceController": "10.0.3" } }, "Microsoft.PowerShell.Commands.Utility": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "lvVh2zHEC2EnBImCRpu9b+5qqngE5o76gVI1NzIfReBXNtsym51XmX/kCrN0INm98CN3GoxTBa7WTcTJC1H3dw==", + "resolved": "7.6.0-rc.1", + "contentHash": "Bfece2H3c83JEoWQyrf6ka/OMEjJz4hS69GUzuLJOldtrRASnlJ8e6cIg3hjKomh2JTk+QjfL/Jt0GKTAz1nAw==", "dependencies": { - "Json.More.Net": "2.0.2", - "JsonPointer.Net": "5.0.2", - "JsonSchema.Net": "7.2.3", - "Markdig.Signed": "0.38.0", - "Microsoft.CodeAnalysis.Analyzers": "3.11.0", - "Microsoft.CodeAnalysis.CSharp": "4.11.0", + "JsonSchema.Net": "7.4.0", + "Markdig.Signed": "0.44.0", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", "Microsoft.PowerShell.MarkdownRender": "7.2.1", - "Microsoft.Win32.SystemEvents": "9.0.10", - "System.Drawing.Common": "9.0.10", - "System.Management.Automation": "7.5.4", - "System.Reflection.Metadata": "8.0.1", - "System.Threading.AccessControl": "9.0.10" + "Microsoft.Win32.SystemEvents": "10.0.3", + "System.Drawing.Common": "10.0.3", + "System.Management.Automation": "7.6.0-rc.1" } }, "Microsoft.PowerShell.ConsoleHost": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "0U3DkO631KXj5m4jfsKQrUT795ZvZZAzvjNTNdhO4YNukwSSSzJUTszVVE2NXwbkQZHuAoUjPTwicINZJ87OoQ==", + "resolved": "7.6.0-rc.1", + "contentHash": "tzijTbDr1gUtSTx17013KEtJS9cqU+7FXF8LldxXQOlNRnxTgJBhg4aw88EXdBDVXFe9ON1d1YpD6BignLk+6Q==", "dependencies": { - "System.Management.Automation": "7.5.4" + "System.Management.Automation": "7.6.0-rc.1" } }, "Microsoft.PowerShell.CoreCLR.Eventing": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "1xyl5hcWKs5IDFO1ZWXSoVLPN78CJpo6GykVg3F/kNHkldixODi6yz1bbVmyEAMC64AvA3ZKSs/AZaGNoKTI+w==", - "dependencies": { - "System.Diagnostics.EventLog": "9.0.10" - } + "resolved": "7.6.0-rc.1", + "contentHash": "4b0NmZ3mdlluet21RbUXkEMG9+aIY9f+BN2bLy/zmTeFq36MFkGE80JoY0NfIzwwJJA+NpfSj4vXWTYIwujr8Q==" }, "Microsoft.PowerShell.MarkdownRender": { "type": "Transitive", @@ -659,15 +395,15 @@ }, "Microsoft.PowerShell.Native": { "type": "Transitive", - "resolved": "7.4.0", - "contentHash": "FlaJ3JBWhqFToYT0ycMb/Xxzoof7oTQbNyI4UikgubC7AMWt5ptBNKjIAMPvOcvEHr+ohaO9GvRWp3tiyS3sKw==" + "resolved": "700.0.0-rc.1", + "contentHash": "lJOCErHTSWwCzfp3wgeyqhNRi4t43McDc0CHqlbt3Cj3OomiqPlNHQXujSbgd+0Ir6/8QAmvU/VOYgqCyMki6A==" }, "Microsoft.PowerShell.Security": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "k/TMcn7ETkq91qhzncGbHthOEzZjGzcq6U6E4exyJRsRqe2MqaRXGrPifiCXDJ6I/dSQOclSpqFSqE/SWVUFdQ==", + "resolved": "7.6.0-rc.1", + "contentHash": "Lcsqavzb3jp9MqLFr9TCsxFBylepRGUhPhkRAYDeSMYohK6K4fMkdKudWo23M5ZKgooNySkkQX2H1jF7w0ZU7w==", "dependencies": { - "System.Management.Automation": "7.5.4" + "System.Management.Automation": "7.6.0-rc.1" } }, "Microsoft.Security.Extensions": { @@ -677,72 +413,64 @@ }, "Microsoft.Win32.Registry.AccessControl": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "ZYHfH0wgTa4usqMMetFYezSjfkQaMat83b/Ykz1q4qSx1h/OiXFb8ZSsn3ZKttHcxe1bn5m/+Zjz9deVT45L8w==" + "resolved": "10.0.3", + "contentHash": "CxgQc/IHtQ2IhqRdN6nxZZwk/C+dnbE5GLWErze4jAUgCkPbGe+hlgbESMop57FQMGShOqrWZWQAKpZ65QTU8g==" }, "Microsoft.Win32.SystemEvents": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "P1CEtsxar/RhfoH3r1vc9ra28LLVYphpcFBxyRIEMM/jP3qh4j9TU4sWH2RUhMZX+GbFxZ+zz1oSP2n9MwjshA==" + "resolved": "10.0.3", + "contentHash": "gYpwz5Gl0rs9pEFHBKctLbSi7SUGR4L1uRjXkU488nizWd2hvo2JP2+ATUsv0th7v6LDiXfdlUsnTbvC2MauFA==" }, "Microsoft.Windows.Compatibility": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "nCkAfadYeJNfJE/RoKGKFlIHVzovN6/DhLm4ebaCBiLWnP6R/fe22n3BWWqlkT2ignu+GTBrkNLs64e8yCCmGw==", - "dependencies": { - "Microsoft.Win32.Registry.AccessControl": "9.0.10", - "Microsoft.Win32.SystemEvents": "9.0.10", - "System.CodeDom": "9.0.10", - "System.ComponentModel.Composition": "9.0.10", - "System.ComponentModel.Composition.Registration": "9.0.10", - "System.Configuration.ConfigurationManager": "9.0.10", - "System.Data.Odbc": "9.0.10", - "System.Data.OleDb": "9.0.10", + "resolved": "10.0.3", + "contentHash": "MIoN5L2qs5cqClZ3vvH0ZG4QxaSEAYyNeR7B5k88y0l55MKAKCn4uSfposEwpXeHqZrI1uUevA/c5YUe6hcAIw==", + "dependencies": { + "Microsoft.Win32.Registry.AccessControl": "10.0.3", + "Microsoft.Win32.SystemEvents": "10.0.3", + "System.CodeDom": "10.0.3", + "System.ComponentModel.Composition": "10.0.3", + "System.ComponentModel.Composition.Registration": "10.0.3", + "System.Configuration.ConfigurationManager": "10.0.3", + "System.Data.Odbc": "10.0.3", + "System.Data.OleDb": "10.0.3", "System.Data.SqlClient": "4.9.0", - "System.Diagnostics.EventLog": "9.0.10", - "System.Diagnostics.PerformanceCounter": "9.0.10", - "System.DirectoryServices": "9.0.10", - "System.DirectoryServices.AccountManagement": "9.0.10", - "System.DirectoryServices.Protocols": "9.0.10", - "System.Drawing.Common": "9.0.10", - "System.IO.Packaging": "9.0.10", - "System.IO.Ports": "9.0.10", - "System.Management": "9.0.10", - "System.Reflection.Context": "9.0.10", - "System.Runtime.Caching": "9.0.10", - "System.Security.Cryptography.Pkcs": "9.0.10", - "System.Security.Cryptography.ProtectedData": "9.0.10", - "System.Security.Cryptography.Xml": "9.0.10", - "System.Security.Permissions": "9.0.10", - "System.Security.Principal.Windows": "5.0.0", - "System.ServiceModel.Duplex": "4.10.3", - "System.ServiceModel.Http": "4.10.3", - "System.ServiceModel.NetTcp": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3", - "System.ServiceModel.Security": "4.10.3", - "System.ServiceModel.Syndication": "9.0.10", - "System.ServiceProcess.ServiceController": "9.0.10", - "System.Speech": "9.0.10", - "System.Text.Encoding.CodePages": "9.0.10", - "System.Threading.AccessControl": "9.0.10", - "System.Web.Services.Description": "4.10.3" + "System.Diagnostics.PerformanceCounter": "10.0.3", + "System.DirectoryServices": "10.0.3", + "System.DirectoryServices.AccountManagement": "10.0.3", + "System.DirectoryServices.Protocols": "10.0.3", + "System.Drawing.Common": "10.0.3", + "System.IO.Packaging": "10.0.3", + "System.IO.Ports": "10.0.3", + "System.Management": "10.0.3", + "System.Reflection.Context": "10.0.3", + "System.Runtime.Caching": "10.0.3", + "System.Security.Cryptography.Pkcs": "10.0.3", + "System.Security.Cryptography.ProtectedData": "10.0.3", + "System.Security.Permissions": "10.0.3", + "System.ServiceModel.Http": "8.1.2", + "System.ServiceModel.NetTcp": "8.1.2", + "System.ServiceModel.Primitives": "8.1.2", + "System.ServiceModel.Syndication": "10.0.3", + "System.ServiceProcess.ServiceController": "10.0.3", + "System.Speech": "10.0.3", + "System.Web.Services.Description": "8.1.2" } }, "Microsoft.WSMan.Management": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "VaLrRXOuIlflS1zonDAbuKdADLojCeSdDy4d4vILa1l2SO3Yaheh3gGP3g4emPCeVDN75ZokigH8Ehe0OeNO1A==", + "resolved": "7.6.0-rc.1", + "contentHash": "AsZLXiO6qyrvmk6e4S4qabpbysq+5+veGmhDJUDMJZdyq0ORmFB7GiWD5aYHwSebKeJSItV51lwNcIdo1JgbQA==", "dependencies": { - "Microsoft.WSMan.Runtime": "7.5.4", - "System.Diagnostics.EventLog": "9.0.10", - "System.Management.Automation": "7.5.4", - "System.ServiceProcess.ServiceController": "9.0.10" + "Microsoft.WSMan.Runtime": "7.6.0-rc.1", + "System.Management.Automation": "7.6.0-rc.1", + "System.ServiceProcess.ServiceController": "10.0.3" } }, "Microsoft.WSMan.Runtime": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "Kw5tys1LdJRl/Sn3qT5Os0VJev1o5TGPPfrd7SfxUFiHLcYeiO0IQGFpumZ9SXr4FxPot1125iu3l2a2VEEBZw==" + "resolved": "7.6.0-rc.1", + "contentHash": "HFG8NIyKCrZ7SM/lgewFpEQ13kbsRQm6c8ihdo2v+Am0XfftCw+cxzKOpNXlti8ZBEkPksZzkbxlPeAds0PZGA==" }, "Newtonsoft.Json": { "type": "Transitive", @@ -752,18 +480,13 @@ "Npgsql": { "type": "Transitive", "resolved": "10.0.0", - "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0" - } + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==" }, "OpenTelemetry": { "type": "Transitive", "resolved": "1.15.0", "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", "dependencies": { - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging.Configuration": "10.0.0", "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" } }, @@ -777,7 +500,6 @@ "resolved": "1.15.0", "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", "OpenTelemetry.Api": "1.15.0" } }, @@ -791,8 +513,6 @@ "resolved": "8.4.2", "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.Extensions.Options": "8.0.0", "Polly.Core": "8.4.2" } }, @@ -801,79 +521,78 @@ "resolved": "8.4.2", "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", "dependencies": { - "Polly.Core": "8.4.2", - "System.Threading.RateLimiting": "8.0.0" + "Polly.Core": "8.4.2" } }, "runtime.android-arm.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "KUeHD0wRFCTS9QHantD5Cv/RzDzVY/mQP1Z/eKLtlX5A5SZvsqeomAoayPdh/QmgSzquoHeIDMAMp8VVU+Xzag==" + "resolved": "10.0.3", + "contentHash": "6W4qZX0X7FF+PHM9Kaa5ZsTLcGJAzCU7FB4Tjy1vTg2rUIEjDqijWTtpz8vY6gBzZaG+tD0/EKUyGnfq6d/d/Q==" }, "runtime.android-arm64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "b+z8JoBrZ5TMXiXeh0s+s9/uIVx6PmulEuMaN81JLM68aAb4DWHi7t5CL+8bWJhsFhd8VAYoZ9pi5miNTFPeuA==" + "resolved": "10.0.3", + "contentHash": "4OdNg2Du1kvm0b4tSErkA4wfH32YmgUqeSLzekk6NdCR61brCp4vttTJl0epZMwbRppXuy/jY7pV0WtP4ymnfQ==" }, "runtime.android-x64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "K7u+/G2gPoRLNc974p1Tnp44VRLlpQWZrKEQofBTpyJZPgd46ayvXayqT4jyGodG4O6Q6+yY2pYUYlqv1K2l6w==" + "resolved": "10.0.3", + "contentHash": "nP7xJSDSqRktV4kEd0kr4n0xhzJFuQjh7L/AgrKB3C4QK5TA/8NimJmTl1ogngvnXn2vilH2Hk+keBIvuoGWCA==" }, "runtime.android-x86.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "TGT4P40ockzrZf/K46A3VAl2dC2PWAS6WhqqtZJbH5G7XgBMX/FoaoY6DtFtz6u7RVl4zhjdG3QWXEv/u/1Hlg==" + "resolved": "10.0.3", + "contentHash": "f/neqijoVpgl/PcrXYeLNfCC2t74XYIcaHvtSaF3zaIdJ3hf5wrwRC9gzYLXHRLg/j4DRsCHEw8rlux/dNh7XA==" }, "runtime.linux-arm.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "Y2EEaUtO1JolypkFcqgsDxjmOleHa7d9OxBY4Osw5vIdQpOfP0Qj30czQfkN7cZTQH8NxsSr5WawVbk5yFabFg==" + "resolved": "10.0.3", + "contentHash": "cwiQmy95Zd42K4kMzDt8GkXNKHWDkRZIIyb3MteRrgJKubH2DMA6VY3JiFeR+1hQ7i31Ck+dYyt8wUkElZ/NrQ==" }, "runtime.linux-arm64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "nIFQGoz22wdgtmS4Ce+weqGUBh1kpO4XbNEgCU01+7P/+yZAb+gbRSeJUyUmCPhyW0S8FhX1xgJDH/SiJgP05Q==" + "resolved": "10.0.3", + "contentHash": "cEx+Xm0ZNPsuoYTFwXZ6qRstwXQ7vJjbb3jWzo5s5xIeEgpTDdfiUjTgK3Gl618mWgb7+Gn6Vt5pT07RxFC28Q==" }, "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "jV3esGC4j69yPlRzj50EbJq4syweBm4rOWKYJ3nWCMbVzTW1YQ2o4QhiVjCDOEKEf6q5eVGEaa6fyVXQ/K95Hw==" + "resolved": "10.0.3", + "contentHash": "kB21sNzlKgTj3V/LZhnTLFuViiGKtkiUG2GdEW+z3jdeUtRoxqBBXryzMqy0oUOy5idBW2pA6bh+iLGaj+GThA==" }, "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "UfoiSWuf75mYJPknRXSezDoFYaCp5dWoUjASjg6gQSa7FD2G59Mee6vMEzHFS+x8N+H8oNnL9TCIZUD4/8e/2Q==" + "resolved": "10.0.3", + "contentHash": "/Ud7EYdpnBGJ/x7DdqVFEJOQtEcf9rsD6/1iyUBJNfhhs8JljZLewJHp1cXr1DVCaWWc9jSWIc4g+QpMct/22A==" }, "runtime.linux-musl-arm.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "CWwqjMVVtYiW4I9wk9YuUaSxxkPZKZ/BKj5ppAsIZv4X9u/dyh8+Qbj3Fly61uUXpGxXU4QFQhYuPi5pJTAOBA==" + "resolved": "10.0.3", + "contentHash": "pBubukkmXD9e4Ju004bEHL3TE5mYahONCVwrJ5b+7/cj8smSY2H5sKTyURiVF1EfX55yORKyTyQ3dALA5yTTlg==" }, "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "NxMtoYPV8lreQggsXWsXXRF/djycKieThc4O2kxGB6EgjsiRDuNdnbODV0lWGV6v4mn08uQvuOhmBP5ZYpVdkA==" + "resolved": "10.0.3", + "contentHash": "+FR6SvpZya66Wv26MVEK4l2gnjfZnBVUz675eaTcMQ/KWAy+GMaPyhRaBIMnQJf5gfuu3x1qgeAzpmeFkOPGBw==" }, "runtime.linux-musl-x64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "ZNQ/D4lUGxTbfGAZRWP4vp7tCLqvInit03YXAiFXDWh/DnMEosBjrwcu8vbWgSsF01DUbyZQai8lwAStIZWo8w==" + "resolved": "10.0.3", + "contentHash": "Ee1XnOSKgeJGBcnd7LLLkyXEbE/JcpQ3pUlbPXqugl0YD55jVubpYebzGYI+ilrnBLCRiV0BR822b2XmerIKGw==" }, "runtime.linux-x64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "mOTksL8qwN9EiWz71Dzhc98iQhKmtHlWH5GbhiCJ+ES2ei1HLr2bXSNMrXRd5s0Wfzg6xeQmT5M9umcgtv8Bzg==" + "resolved": "10.0.3", + "contentHash": "U+Zr1KuBDIIwgR2gTMFbBAtkr4WnKdEXxmknPa3X8Qy6oCi4+MTRmq2zmTh7wxXQXTdmhfXg2tyy7DuVHtB+xQ==" }, "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "aKoLfCdLoQGhPh2VYBn6sn1kDuwgtXKJ4D2Ql/2WLCGCuXestpxLBS0JhSVBFSp1HrFdazj4aSwpYurtes+1Gg==" + "resolved": "10.0.3", + "contentHash": "9yTgh3ZKCBTGEO4YS76g/K15s2qSdeEZDqWteixUPeRsETsuBUwUPF39Fk39OPlRgwhqEuhtHy1Hf6BSOETfAg==" }, "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "B+boSbUptH2fiKiUXBIu5hlQ3oH+nAdPY9TNmpU10nuoFW/DNz21fCXY3UOIz9oRXp5Ao2b7RlurgpBl0AgoOQ==" + "resolved": "10.0.3", + "contentHash": "0gxqUZYQ1T1o5VzgyEMKiSPKJtPbleF2nbjZa5QKZQvPgSlrZFL+FrW4LywIk2scOpXu+Vd5Mv8AGGV7slhYWA==" }, "runtime.native.System.Data.SqlClient.sni": { "type": "Transitive", @@ -887,36 +606,36 @@ }, "runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "7AzXN+J8PkTVctfymH+tEaAmj0wNKFPyACqp5cYff0DrHxnsQhv7xtRWxJRrQ0azOAFGR1mhWN4aM1QkbQQ0Rw==", - "dependencies": { - "runtime.android-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.android-x86.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.linux-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.osx-arm64.runtime.native.System.IO.Ports": "9.0.10", - "runtime.osx-x64.runtime.native.System.IO.Ports": "9.0.10" + "resolved": "10.0.3", + "contentHash": "7U3HW0JzAeg9PWaYBcyWLFMq2RSexgJ8uVCR3E2QVJJ/IZnRT0wYRhyEx3zBSbKYzN3KE6MDiRd6CN4hR7YkqA==", + "dependencies": { + "runtime.android-arm.runtime.native.System.IO.Ports": "10.0.3", + "runtime.android-arm64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.android-x64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.android-x86.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-arm.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-arm64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-bionic-arm64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-bionic-x64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-musl-arm.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-musl-arm64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-musl-x64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.linux-x64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.maccatalyst-arm64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.maccatalyst-x64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.osx-arm64.runtime.native.System.IO.Ports": "10.0.3", + "runtime.osx-x64.runtime.native.System.IO.Ports": "10.0.3" } }, "runtime.osx-arm64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "m+gRRrmCTwP30YiVnFeZg/zRWgzVcOlN28cIPMkK11C9UU60waLknTnRLlQUagIkWaCDifKJCB6wtEeca5QiMA==" + "resolved": "10.0.3", + "contentHash": "qnEjXlIazxaRAhBet0EupWoJQ9PKsXjThpMKGP/ZsCjZgHEb+zs9wft82wY27OagyPOCg9D4hJHWKt5LTbJNZg==" }, "runtime.osx-x64.runtime.native.System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "89HgF1Oplzjomn0BLeqJxEC17d//zmbs7CMKT12ZvjvFMvpMFO8uQUZ9xRIh91rM0ByfbhSobe2IRezjpeDNlg==" + "resolved": "10.0.3", + "contentHash": "L1gcOL5kcfeeujKQbqJmpeTLsT0c6DeaJ9w6PY9oZ+/WESyya+Zj7StKguXR5j93o3o86FjcN05yufpoCz7R2g==" }, "runtime.win-arm64.runtime.native.System.Data.SqlClient.sni": { "type": "Transitive", @@ -943,9 +662,6 @@ "resolved": "10.0.0", "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", "Serilog": "4.3.0", "Serilog.Extensions.Logging": "10.0.0" } @@ -955,7 +671,6 @@ "resolved": "10.0.0", "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", "dependencies": { - "Microsoft.Extensions.Logging": "10.0.0", "Serilog": "4.2.0" } }, @@ -972,7 +687,6 @@ "resolved": "10.0.0", "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", "dependencies": { - "Microsoft.Extensions.Configuration.Binder": "10.0.0", "Microsoft.Extensions.DependencyModel": "10.0.0", "Serilog": "4.3.0" } @@ -997,10 +711,7 @@ "SQLitePCLRaw.core": { "type": "Transitive", "resolved": "2.1.11", - "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==", - "dependencies": { - "System.Memory": "4.5.3" - } + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" }, "SQLitePCLRaw.lib.e_sqlite3": { "type": "Transitive", @@ -1017,49 +728,43 @@ }, "System.CodeDom": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "00dAIR9Zx+F+AaipjaQmudX3VVpzYvT0bKVD3WcJq6om6pKNrldnp5bSR0VV6IlwDBa1HObGD+sTFaT/I9bBng==" - }, - "System.Collections.Immutable": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + "resolved": "10.0.3", + "contentHash": "+G1mBhHJp8taiDcHK1gmckOZ884n9JeIrS1dzFYZhSa8oTiIF+EagIyAyCOQzdBbbES7eb9AkjGBlUZqqoCaeg==" }, "System.ComponentModel.Composition": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "tLJKLlc3VsjTLZ4aAwKicKfLKTAzTSSod+T6TWQSjmmA2JMgVvsU5QA2Ka2+Gq2M8poLaxY2dAipFsJen+ZI/g==" + "resolved": "10.0.3", + "contentHash": "qBShXNCMMY4rTWvh8Y9BombIyng4LUqbfZd3yx93D88YKf2w1MuYpC8J4XSxSVQDubcoB9AMUwWe6IcVzo3/Sg==" }, "System.ComponentModel.Composition.Registration": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "H+iSxY02ucdevQa+4jc5disuSgiLom2gUrdATFmVFWc/1De5HBtssVdcar2mxDbtT5IBKiMvwXVHrnl5jmaQtw==", + "resolved": "10.0.3", + "contentHash": "Dtb5UBzyHH3RnVbVSOTE62X6eIYmbStV3z4jk7aew34YVTMltAeqN6p5KffqdOo8MO8ARFmpZZDcfwJeIbuGsQ==", "dependencies": { - "System.ComponentModel.Composition": "9.0.10", - "System.Reflection.Context": "9.0.10" + "System.ComponentModel.Composition": "10.0.3", + "System.Reflection.Context": "10.0.3" } }, "System.Configuration.ConfigurationManager": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "5CBhl5dWmckKEtvk8F6GXtmHxNBoqAC8xILxIntNm7AzHiXQ09CXSLhncIJ/cQWaiNYzLjHZCgtMfx9tkCKHdA==", + "resolved": "10.0.3", + "contentHash": "69ZT/MYxQSxwdiiHRtI08noXiG5drj/bXDDZISmeWkNUtbIfYgmTiof16tCVOLTdmSQY7W7gwxkMliKdreWHGQ==", "dependencies": { - "System.Diagnostics.EventLog": "9.0.10", - "System.Security.Cryptography.ProtectedData": "9.0.10" + "System.Security.Cryptography.ProtectedData": "10.0.3" } }, "System.Data.Odbc": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "1GjZfLbeSdfHhKUFhk4oU6f3PSF2DOFILTPLHDuC8Pj7UWvwnl8a+H7LDtwEqIJuZ0O2n0rMjydm+Fn67u0G2w==" + "resolved": "10.0.3", + "contentHash": "/uKHUwmcXdq06LisgTUC85pKx8xFwFBWKoVLwawW88mKIrUXMCtDxE8c1bixoHOWCI2tesvmZuxo6EgyDVZO/w==" }, "System.Data.OleDb": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "LwiN01NosLlqowmrD1ej1qM1O3GVZeQZzbWrTwYLyeQUGyTVt8yVsTgsRnIJmKny1ENdVcQ9WhKUjzBnh37fsQ==", + "resolved": "10.0.3", + "contentHash": "/YVeBa7iHwhvokst2neLLVBwhcTEcOSjLZPPhjBEPHVxkY5WsffnJ6pya2DhdaKsbCYsWAJfyvnoAPGIE1gwYA==", "dependencies": { - "System.Configuration.ConfigurationManager": "9.0.10", - "System.Diagnostics.PerformanceCounter": "9.0.10" + "System.Configuration.ConfigurationManager": "10.0.3", + "System.Diagnostics.PerformanceCounter": "10.0.3" } }, "System.Data.SqlClient": { @@ -1070,279 +775,175 @@ "runtime.native.System.Data.SqlClient.sni": "4.4.0" } }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "uIpKiKp7EWlYZBK71jYP+maGYjDY9YTi/FxBlZoqDzM1ZHZB7gLqUm4jHvRFwaKfR1/Lrt2rQih9LGPIKyNEow==" - }, - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "Jc+az1pTMujPLDn2j5eqSfzlO7j/T1K/LB7THxdfRWOxujE4zaitUqBs7sv1t6/xmmvpU6Xx3IofCs4owYH0yQ==" - }, "System.Diagnostics.PerformanceCounter": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "35eXaOLXv8ATGDVr946gK0sNEEOwuFzhjFjTQftWh0swhLiyIjAD1pu17tu/SVENpKPZwqJ2e7IIcLpIs0GEzQ==", + "resolved": "10.0.3", + "contentHash": "pQHdkk3QNDYRsNzWVUIamlTiHb3/I50PxGXMRaEqiLmUe+FkInuMj92lCOhvTT6KbLlnMWGRgZq1rDaMJdQixw==", "dependencies": { - "System.Configuration.ConfigurationManager": "9.0.10" + "System.Configuration.ConfigurationManager": "10.0.3" } }, "System.DirectoryServices": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "dlSYvBLD/XlW2y7hJA+INfcRRtkouFSEcYSVoYmxwfurVdYJ088+PUYf8kgszAp3cThpMPAPVhNHl1lMYrv9kw==" + "resolved": "10.0.3", + "contentHash": "sqjtVOs35ortRSABZBhUq1YYtP9ScGMyix+F12K214KkAnfL1D8dS3oXl4U5uA4tsuP/WAiFf3rMvktkh/x1hA==" }, "System.DirectoryServices.AccountManagement": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "5sNlMrUPhEH8gmosdAz2ZuKA4S4fBdnkpgw5C9IIgyZzy8xg8wPj9aX5oBhoep48tqDVz0++DBWJxJsi4UjT+A==", + "resolved": "10.0.3", + "contentHash": "mllqB05qE2q9AHpyy5yKMSdz66AKCYQPP1I7Ny4+YjaMy7ttI91Ga/NWjOf7iPCXv1PHvgKHVtDrltm7yGKJPw==", "dependencies": { - "System.Configuration.ConfigurationManager": "9.0.10", - "System.DirectoryServices": "9.0.10", - "System.DirectoryServices.Protocols": "9.0.10" + "System.Configuration.ConfigurationManager": "10.0.3", + "System.DirectoryServices": "10.0.3", + "System.DirectoryServices.Protocols": "10.0.3" } }, "System.DirectoryServices.Protocols": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "nyJa6GTsPxNYt08Ssl9xHXLyDGozVkmsWgmAegUw9+4TBvS8BO1oV69XlkbyF+oJ6qR4+VPy7lgDWUMapvQfUg==" + "resolved": "10.0.3", + "contentHash": "LsmWlhBLelsI0+oHjgZd+WeeX/60TazKv/7xE+z88zONMerx2JWklfmpmOO6VHr1sys5bIJWHtjxhjRruVa4jg==" }, "System.Drawing.Common": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "FDakPhIcxHnhslLiz4ZQ+ALpHRpCU3zOep9Mcq+4hL23XwQrzmgJNYvf1tH4kJ/V36wO/ZhRr8nOfiz26P3wKg==", + "resolved": "10.0.3", + "contentHash": "bJlT89G7EqMpiLCvIPmb7BHpcSxjVeFsahRyQk3/mrUt5YgHKiFcEv88db97gKcpRn5opxfBwI0ohUmJlxuGJg==", "dependencies": { - "Microsoft.Win32.SystemEvents": "9.0.10" + "Microsoft.Win32.SystemEvents": "10.0.3" } }, "System.IO.Packaging": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "dKlnLbyOKFCLa5rda8yUU6M0HhVLMkB7rf9lEWnXVtHdNlq9A/fJmt7s/OhwbYaUfOO8rxshpQLyPn0Pv1a2lQ==" + "resolved": "10.0.3", + "contentHash": "/4CRIbxg4yhfUOCUjochR+iZKLUnewZ4gd3y9iQlKnFLzHkVFYEaO/WARPVgSBZ9W3+yCrERxlA2/4FXOm80og==" }, "System.IO.Ports": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "jMvwu+NOk/+vlOzTp9vpxIeGq+yRA+3EbkmpLMs37AAy9cI8YlY/ntTHL00w26Tvu6cIkx0/TdjmeHm0l99Nqw==", + "resolved": "10.0.3", + "contentHash": "Zs04mZ/dQtaFQ+hpQNDtijBs+6aM9j2fQPp8zNZTfh8DboVNNv7Sw6gH00hT+PVcAhEozlfT+n59Svm6Ug8ROA==", "dependencies": { - "runtime.native.System.IO.Ports": "9.0.10" + "runtime.native.System.IO.Ports": "10.0.3" } }, "System.Management": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "kJY2C6MjKSqfRkEnc8gn4Jth81Anrgxxpu0MffjEadfpp0Ll/gdGpYnDhRWZd+iFttkfZC0uCjFmCrZARRqq4w==", + "resolved": "10.0.3", + "contentHash": "VyK/nnG1ZgwP/wY8HGrNrdha7kenyI+bkfE0miywiRaWVwqtenuYshA6pmP6Xm7lPsTE2ZRZ+BkzmUVg/VZtTg==", "dependencies": { - "System.CodeDom": "9.0.10" + "System.CodeDom": "10.0.3" } }, "System.Management.Automation": { "type": "Transitive", - "resolved": "7.5.4", - "contentHash": "kHvz4Gc2sQ670KNU+CMsCmoxSM+hO+qW9ujyf3MbBuDImuKeHL8oo2gq4kZpuncO/MSeOTstx3pW8YE6jqIZYA==", + "resolved": "7.6.0-rc.1", + "contentHash": "1uKnZxJh3AHXo5tM9Isv3kBlMZPIcXUv3fP46E3/L1I1oCleeYgD1uewdarAGbs1Z2B3qXzIxdM+5WkeoKi7GQ==", "dependencies": { - "Microsoft.ApplicationInsights": "2.22.0", + "Microsoft.ApplicationInsights": "2.23.0", "Microsoft.Management.Infrastructure": "3.0.0", - "Microsoft.PowerShell.CoreCLR.Eventing": "7.5.4", - "Microsoft.PowerShell.Native": "7.4.0", + "Microsoft.PowerShell.CoreCLR.Eventing": "7.6.0-rc.1", + "Microsoft.PowerShell.Native": "700.0.0-rc.1", "Microsoft.Security.Extensions": "1.4.0", - "Microsoft.Win32.Registry.AccessControl": "9.0.10", + "Microsoft.Win32.Registry.AccessControl": "10.0.3", "Newtonsoft.Json": "13.0.4", - "System.CodeDom": "9.0.10", - "System.Configuration.ConfigurationManager": "9.0.10", - "System.Diagnostics.DiagnosticSource": "9.0.10", - "System.Diagnostics.EventLog": "9.0.10", - "System.DirectoryServices": "9.0.10", - "System.Management": "9.0.10", - "System.Security.AccessControl": "6.0.1", - "System.Security.Cryptography.Pkcs": "9.0.10", - "System.Security.Cryptography.ProtectedData": "9.0.10", - "System.Security.Permissions": "9.0.10", - "System.Text.Encoding.CodePages": "9.0.10", - "System.Windows.Extensions": "9.0.10" + "System.CodeDom": "10.0.3", + "System.Configuration.ConfigurationManager": "10.0.3", + "System.DirectoryServices": "10.0.3", + "System.Management": "10.0.3", + "System.Security.Cryptography.Pkcs": "10.0.3", + "System.Security.Cryptography.ProtectedData": "10.0.3", + "System.Security.Permissions": "10.0.3", + "System.Windows.Extensions": "10.0.3" } }, - "System.Memory": { - "type": "Transitive", - "resolved": "4.5.3", - "contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA==" - }, "System.Net.Http.WinHttpHandler": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "D7CvYoTJPp/gDP3CMKxyUXUpfs8pFi4mQs+USHlT3Bqq6b83Lqe7gOn/dVPVZ78d2/cimxcqnpB9N2f1cDllWg==" - }, - "System.Numerics.Vectors": { - "type": "Transitive", - "resolved": "4.5.0", - "contentHash": "QQTlPTl06J/iiDbJCiepZ4H//BVraReU4O4EoRw1U02H5TLUIT7xn3GnDp9AXPSlJUDyFs4uWjWafNX6WrAojQ==" - }, - "System.Private.ServiceModel": { - "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "BcUV7OERlLqGxDXZuIyIMMmk1PbqBblLRbAoigmzIUx/M8A+8epvyPyXRpbgoucKH7QmfYdQIev04Phx2Co08A==", - "dependencies": { - "Microsoft.Bcl.AsyncInterfaces": "5.0.0", - "Microsoft.Extensions.ObjectPool": "5.0.10", - "System.Numerics.Vectors": "4.5.0", - "System.Reflection.DispatchProxy": "4.7.1", - "System.Security.Cryptography.Xml": "6.0.1", - "System.Security.Principal.Windows": "5.0.0" - } + "resolved": "10.0.3", + "contentHash": "BjcA6p1XJk9prN0Ekrg74SLxeXWVNjbdY4L9WWCbSbKxCeLodOWZROKOHn5M1kJomfLn5a4aqFcVD57/6IQStg==" }, "System.Reflection.Context": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "Dv7cY++FuibtTyQfWR7ZVMjdtblYkRH6po+UiyBsUwNri2T+afSqwpZq4F2zsVGxtsNsZpXbrJCDs4PxvwxMrQ==" - }, - "System.Reflection.DispatchProxy": { - "type": "Transitive", - "resolved": "4.7.1", - "contentHash": "C1sMLwIG6ILQ2bmOT4gh62V6oJlyF4BlHcVMrOoor49p0Ji2tA8QAoqyMcIhAdH6OHKJ8m7BU+r4LK2CUEOKqw==" - }, - "System.Reflection.Metadata": { - "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "+4sz5vGHPlo+5NpAxf2IlABnqVvOHOxv17b4dONv4hVwyNeFAeBevT14DIn7X3YWQ+eQFYO3YeTBNCleAblOKA==" + "resolved": "10.0.3", + "contentHash": "5uMIsgbNDFKFL5G+N/3S+nJBxxJKOQOrCLYrPMfgzqjgFADiBr8EDOPUFY+YS4KPcFduOJnqXvhLfqYIZeNJVQ==" }, "System.Runtime.Caching": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "WFKbtzR8mfIZWeQlYGtyjMcse3DoNR0zLsNAev2dDYM8pY945EzzLPO84qnVa+BIEDF1woD8+TtboWSh65U2DQ==", + "resolved": "10.0.3", + "contentHash": "+93HrquK0Wen9JL/Suli4mlyaVT/7Fo3mJPp3ozRvBskGx5rgRX5rNqna8XwvHTzCad9YP2wt5H3m2l0KysECQ==", "dependencies": { - "System.Configuration.ConfigurationManager": "9.0.10" + "System.Configuration.ConfigurationManager": "10.0.3" } }, - "System.Security.AccessControl": { - "type": "Transitive", - "resolved": "6.0.1", - "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" - }, "System.Security.Cryptography.Pkcs": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "Pg7QZz80fOJZrtJnAdEAIpeor8q7F1ofwXGYgLNr4dR8Mqf2l7lfeTaodQkRetrj+ClQwVVYoyi6g2eOsmstFw==" - }, - "System.Security.Cryptography.Xml": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "kkEBXInhetgK1+E0NzDSz4S2Yh3wpivGf1A7I88dN4SYINGrQnGciGDJj1RTgsE/zFeJNlAZhXs4XSqn7q8AhQ==", - "dependencies": { - "System.Security.Cryptography.Pkcs": "9.0.10" - } + "resolved": "10.0.3", + "contentHash": "Vwbm2siKxaGl515m/5C32J4VCG6VmytrH2ACV6hcWtfj4XQ1zN0cjuuDs49QoDi6/QS3pl2/wPyDYgODO9KxYA==" }, "System.Security.Permissions": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "uqzXSkn2nx9nplIdayurMtbLcQQdOGd7TmIQ+X5P65+QWT2S+1aUZfJuH2f+Blr/4W6wxMkiX9aKzLk7lfMZFQ==", + "resolved": "10.0.3", + "contentHash": "kuqE2IzQ4lE92519tV9z5asKOXk/budCwryp1C53zCdaUls0+mWlGfmxiiQOpOkVkPPeIx1U7yCswaknBjhEOQ==", "dependencies": { - "System.Windows.Extensions": "9.0.10" + "System.Windows.Extensions": "10.0.3" } }, - "System.Security.Principal.Windows": { - "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" - }, - "System.ServiceModel.Duplex": { + "System.ServiceModel.Http": { "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "IZ8ZahvTenWML7/jGUXSCm6jHlxpMbcb+Hy+h5p1WP9YVtb+Er7FHRRGizqQMINEdK6HhWpD6rzr5PdxNyusdg==", + "resolved": "10.0.652802", + "contentHash": "G02XZvmccf42QCU5MjviBIg69MSMAVHwL1inVPsNSpfp5g+t5BkQM3DyvWRLN4qmeFDWSF/mA1rIYONIDu/6Dg==", "dependencies": { - "System.Private.ServiceModel": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3" + "System.ServiceModel.Primitives": "10.0.652802" } }, - "System.ServiceModel.Http": { + "System.ServiceModel.NetFramingBase": { "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "hodkn0rPTYmoZ9EIPwcleUrOi1gZBPvU0uFvzmJbyxl1lIpVM5GxTrs/pCETStjOXCiXhBDoZQYajquOEfeW/w==", + "resolved": "10.0.652802", + "contentHash": "8/wx/Xnfm9LmGmK0banr05JJYNZmJzlxa8J5lfR7v3MM78QzSG8C3/HDi0/BjlOMeMZd21sX7oEFUhoucrk49w==", "dependencies": { - "System.Private.ServiceModel": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3" + "System.ServiceModel.Primitives": "10.0.652802" } }, "System.ServiceModel.NetTcp": { "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "tP7GN7ehqSIQEz7yOJEtY8ziTpfavf2IQMPKa7r9KGQ75+uEW6/wSlWez7oKQwGYuAHbcGhpJvdG6WoVMKYgkw==", + "resolved": "10.0.652802", + "contentHash": "VFQgu0IRWUPuPTxHZkMmhPNGYqcu9RwpFcZpW5L941dunUY8nJAErtAWEZYKnj2zAWsm/88nLAEoFc4cuoC2zw==", "dependencies": { - "System.Private.ServiceModel": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3" + "System.ServiceModel.NetFramingBase": "10.0.652802", + "System.ServiceModel.Primitives": "10.0.652802" } }, "System.ServiceModel.Primitives": { "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "aNcdry95wIP1J+/HcLQM/f/AA73LnBQDNc2uCoZ+c1//KpVRp8nMZv5ApMwK+eDNVdCK8G0NLInF+xG3mfQL+g==", - "dependencies": { - "System.Private.ServiceModel": "4.10.3" - } - }, - "System.ServiceModel.Security": { - "type": "Transitive", - "resolved": "4.10.3", - "contentHash": "vqelKb7DvP2inb6LDJ5Igi8wpOYdtLXn5luDW5qEaqkV2sYO1pKlVYBpr6g6m5SevzbdZlVNu67dQiD/H6EdGQ==", - "dependencies": { - "System.Private.ServiceModel": "4.10.3", - "System.ServiceModel.Primitives": "4.10.3" - } + "resolved": "10.0.652802", + "contentHash": "ULfGNl75BNXkpF42wNV2CDXJ64dUZZEa8xO2mBsc4tqbW9QjruxjEB6bAr4Z/T1rNU+leOztIjCJQYsBGFWYlw==" }, "System.ServiceModel.Syndication": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "jWOXgKi51ULlPDi+YIWsZglIYUYC1DixAs2j6xdy8fzhuxvXO82yUEXv4wFziqzoG1FmTAV/uv5psxb+3MqB7w==" + "resolved": "10.0.3", + "contentHash": "mloJBxfbjYXgfcfMvH40UWwzekATlUzHLMKPQpg4qab+gpHRgQkhgo4DLz3v7Kacn6000sqoNkxiyk0pJ5x/ig==" }, "System.ServiceProcess.ServiceController": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "dmH+qHQ5wMjvEI0M2s6J+vmaU9L9ID2D9DWMFa7FiTfINfo3e3zeL4ljX7Dg5gCnFIULPFip2ej2iIAC3X6MFw==", - "dependencies": { - "System.Diagnostics.EventLog": "9.0.10" - } + "resolved": "10.0.3", + "contentHash": "lOvEbCrTMl4EB7Ckp1suhgcnUEwUs2qRWLYZvquKU33hpRLZfjTXBSHFWQiRshxqd7dA+Nj9Yiw8EbOwd3T/eQ==" }, "System.Speech": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "rtbgAR0AD2yij7tqh/TJFAvsr1KN+Q8hb8JUcAN7uLh5EAkQ8Z4o7bFTQpcZDPec3/KsBFPHZNQS0nTLHEdmwQ==" - }, - "System.Text.Encoding.CodePages": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "UyurlgWm3k3I8LVDmO67GFiu61t4UPzNrTK5fS6K6DADQgIYcFjDnRJWvVls4/R9UjgSL9R+OxJnDXn05fTCCA==" - }, - "System.Text.Encodings.Web": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "znmiJFUa0GGwq7t6ShUKBDRlPsNJaudNFI7rVeyGnRBhiRMegBvu2GRcadThP/QX/a5UpGgZbe6tolDooobj/Q==" - }, - "System.Threading.AccessControl": { - "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "eI/pq6KpqMO8TrQPxg6ZRdJvQqB+dw5Uax56UkDE3ZH6x9jQ7VD+ir4JUPD3XtKCUhDjy9FLGWfUAx5Jbz+K1Q==" - }, - "System.Threading.RateLimiting": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + "resolved": "10.0.3", + "contentHash": "1n6Sn16yLtonZSK7tGzKiGnanuCs38kiE0EIbssNoC6+S8Bx2iih0jNcvU9KxNeRYwMQYXlQUVAUhrkYS+CSHQ==" }, "System.Web.Services.Description": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "6pwntR5vqLOzUPU9GcLVNEASAVf0GFeXoRF4p/SWIiU3073ZbWJ6dJM5cpXgylcbJDjlwPqNx9f5Y4Od0cNfDA==" + "resolved": "8.1.2", + "contentHash": "FziIBleSpygZOBudSeMkawLgfarnSam7paGkTtV9ITyTmw/TdEqB+moS0TeApmNfAMWGbcWXDXr3djckuLgGDg==" }, "System.Windows.Extensions": { "type": "Transitive", - "resolved": "9.0.10", - "contentHash": "6I+OzjcTx2gtZotjDQXEhWdkfPVxRvT9r9nFWsgt9Of6GwLt9szpIlxx0z2dP3dprg6K3zRU/5bbig+zoVKpfg==" + "resolved": "10.0.3", + "contentHash": "TZ/9e7JLxAT3j66XuNh7Bbqfz1m7UZC92uLknoldmALo82OYhrb4e8LGQ0S3umh2M+JQ94qQpLvPXIkmyuMraw==" }, "werkr.common": { "type": "Project", "dependencies": { "Google.Protobuf": "[3.34.0, )", - "Microsoft.AspNetCore.Authorization": "[10.0.3, )", - "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", "Microsoft.IdentityModel.Tokens": "[8.16.0, )", "Werkr.Common.Configuration": "[1.0.0, )" } @@ -1354,7 +955,6 @@ "type": "Project", "dependencies": { "Grpc.Net.Client": "[2.76.0, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.3, )", "System.Security.Cryptography.ProtectedData": "[10.0.3, )", "Werkr.Common": "[1.0.0, )", "Werkr.Data": "[1.0.0, )" @@ -1386,8 +986,7 @@ "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", "dependencies": { "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" } }, "Google.Protobuf": { @@ -1402,8 +1001,7 @@ "resolved": "2.76.0", "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", "dependencies": { - "Grpc.Net.Common": "2.76.0", - "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + "Grpc.Net.Common": "2.76.0" } }, "Grpc.Net.ClientFactory": { @@ -1412,20 +1010,7 @@ "resolved": "2.76.0", "contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==", "dependencies": { - "Grpc.Net.Client": "2.76.0", - "Microsoft.Extensions.Http": "8.0.0" - } - }, - "Microsoft.AspNetCore.Authorization": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", - "dependencies": { - "Microsoft.AspNetCore.Metadata": "10.0.3", - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Grpc.Net.Client": "2.76.0" } }, "Microsoft.Data.Sqlite.Core": { @@ -1444,9 +1029,7 @@ "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", "dependencies": { "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3" } }, "Microsoft.EntityFrameworkCore.Sqlite": { @@ -1456,48 +1039,11 @@ "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", "dependencies": { "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", "SQLitePCLRaw.core": "2.1.11" } }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Configuration.Json": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" - } - }, - "Microsoft.Extensions.Hosting.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3" - } - }, "Microsoft.Extensions.Http.Resilience": { "type": "CentralTransitive", "requested": "[10.3.0, )", @@ -1505,26 +1051,15 @@ "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", "dependencies": { "Microsoft.Extensions.Http.Diagnostics": "10.3.0", - "Microsoft.Extensions.ObjectPool": "10.0.3", "Microsoft.Extensions.Resilience": "10.3.0" } }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" - } - }, "Microsoft.Extensions.ServiceDiscovery": { "type": "CentralTransitive", "requested": "[10.3.0, )", "resolved": "10.3.0", "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", "dependencies": { - "Microsoft.Extensions.Http": "10.0.3", "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" } }, @@ -1534,7 +1069,6 @@ "resolved": "8.16.0", "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", "Microsoft.IdentityModel.Logging": "8.16.0" } }, @@ -1564,7 +1098,6 @@ "resolved": "1.15.0", "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", "dependencies": { - "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", "OpenTelemetry": "1.15.0" } }, diff --git a/src/Werkr.Api/Werkr.Api.csproj b/src/Werkr.Api/Werkr.Api.csproj index 5435cad..597955c 100644 --- a/src/Werkr.Api/Werkr.Api.csproj +++ b/src/Werkr.Api/Werkr.Api.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Werkr.Api/packages.lock.json b/src/Werkr.Api/packages.lock.json index 6903d4c..ed40cf5 100644 --- a/src/Werkr.Api/packages.lock.json +++ b/src/Werkr.Api/packages.lock.json @@ -37,6 +37,23 @@ "Microsoft.OpenApi": "2.0.0" } }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "OPZ/u7fONQFmnyUIDB8SeJtKnyFkj1zJsZ0Ke2Cp17q8hYs6jGmYEFd6Ne4Hdcd6auUdFdV7di+uFo2w+L34NA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, "Microsoft.IdentityModel.JsonWebTokens": { "type": "Direct", "requested": "[8.16.0, )", @@ -120,282 +137,144 @@ "Grpc.Core.Api": "2.76.0" } }, - "Microsoft.AspNetCore.Metadata": { + "Humanizer.Core": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" }, - "Microsoft.EntityFrameworkCore.Abstractions": { + "Microsoft.Build.Framework": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" }, - "Microsoft.EntityFrameworkCore.Analyzers": { + "Microsoft.CodeAnalysis.Analyzers": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" }, - "Microsoft.EntityFrameworkCore.Relational": { + "Microsoft.CodeAnalysis.Common": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" } }, - "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "Microsoft.CodeAnalysis.CSharp": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.3", - "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "SQLitePCLRaw.core": "2.1.11" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" } }, - "Microsoft.Extensions.AmbientMetadata.Application": { + "Microsoft.CodeAnalysis.CSharp.Workspaces": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" } }, - "Microsoft.Extensions.Caching.Abstractions": { + "Microsoft.CodeAnalysis.Workspaces.Common": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" } }, - "Microsoft.Extensions.Caching.Memory": { + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" } }, - "Microsoft.Extensions.Compliance.Abstractions": { + "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3" - } + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" }, - "Microsoft.Extensions.Configuration": { + "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", "resolved": "10.0.3", - "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" }, - "Microsoft.Extensions.Configuration.Binder": { + "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", "resolved": "10.0.3", - "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + "Microsoft.EntityFrameworkCore": "10.0.3" } }, - "Microsoft.Extensions.Configuration.FileExtensions": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": { "type": "Transitive", "resolved": "10.0.3", - "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Physical": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" } }, - "Microsoft.Extensions.DependencyInjection": { + "Microsoft.Extensions.AmbientMetadata.Application": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" - } + "resolved": "10.3.0", + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==" }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { + "Microsoft.Extensions.Compliance.Abstractions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + "resolved": "10.3.0", + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==" }, "Microsoft.Extensions.DependencyInjection.AutoActivation": { "type": "Transitive", "resolved": "10.3.0", - "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==", - "dependencies": { - "Microsoft.Extensions.Hosting.Abstractions": "10.0.3" - } + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", "resolved": "10.0.3", "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" }, - "Microsoft.Extensions.Diagnostics": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } - }, "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { "type": "Transitive", "resolved": "10.3.0", - "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" - } - }, - "Microsoft.Extensions.Features": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "djFt1Jt+2uREWWVQiiA4ilYBDtHHY7nK08c5K8xBD9+XFNw3KDVprylrMkH08bZGK3ZHRAkS7JDV9srfLrcm/g==" - }, - "Microsoft.Extensions.FileProviders.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.FileProviders.Physical": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.FileSystemGlobbing": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" - }, - "Microsoft.Extensions.Http": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "M5gWob3dtzlF14oto1lR1ZuSJrR0gGc+obv7zY9LGmX5y3Ndpve29MrrjqJW/m4CFud4TE/KFUuHjjtwxhCO8g==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==" }, "Microsoft.Extensions.Http.Diagnostics": { "type": "Transitive", "resolved": "10.3.0", "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", "dependencies": { - "Microsoft.Extensions.Http": "10.0.3", "Microsoft.Extensions.Telemetry": "10.3.0" } }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } - }, - "Microsoft.Extensions.Logging.Configuration": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "PBlaoYeusaxNYyN4WFjzcXWlUDSvLUPxy/e6oP1SONOOYA/oBWT2uBmFGJMV9VTtXiXXxCB39LqlYWbsWE4UKA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" - } - }, - "Microsoft.Extensions.ObjectPool": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "dQKlVXzqflsv5X8iDlAN5YmTL1GcLCrOLKo1s9PNdfjqxeu0S/jmWTfiLGno+8+o1qFL3+VFAH5/ftmypN+sPw==" - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" - }, "Microsoft.Extensions.Resilience": { "type": "Transitive", "resolved": "10.3.0", "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", "dependencies": { - "Microsoft.Extensions.Diagnostics": "10.0.3", "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3", "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", "Polly.Extensions": "8.4.2", "Polly.RateLimiting": "8.4.2" @@ -404,16 +283,7 @@ "Microsoft.Extensions.ServiceDiscovery.Abstractions": { "type": "Transitive", "resolved": "10.3.0", - "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Features": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==" }, "Microsoft.Extensions.Telemetry": { "type": "Transitive", @@ -422,8 +292,6 @@ "dependencies": { "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", - "Microsoft.Extensions.Logging.Configuration": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3", "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" } }, @@ -432,10 +300,7 @@ "resolved": "10.3.0", "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", "dependencies": { - "Microsoft.Extensions.Compliance.Abstractions": "10.3.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0" } }, "Microsoft.IdentityModel.Abstractions": { @@ -471,26 +336,36 @@ "Microsoft.OpenApi": { "type": "Transitive", "resolved": "2.0.0", - "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==", + "contentHash": "GGYLfzV/G/ct80OZ45JxnWP7NvMX1BCugn/lX7TH5o0lcVaviavsLMTxmFV2AybXWjbi3h6FF1vgZiTK6PXndw==" + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", "dependencies": { - "System.Text.Json": "8.0.5" + "System.CodeDom": "6.0.0" } }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, "Npgsql": { "type": "Transitive", "resolved": "10.0.0", - "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0" - } + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==" }, "OpenTelemetry": { "type": "Transitive", "resolved": "1.15.0", "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", "dependencies": { - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging.Configuration": "10.0.0", "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" } }, @@ -504,7 +379,6 @@ "resolved": "1.15.0", "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", "OpenTelemetry.Api": "1.15.0" } }, @@ -518,8 +392,6 @@ "resolved": "8.4.2", "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.Extensions.Options": "8.0.0", "Polly.Core": "8.4.2" } }, @@ -528,8 +400,7 @@ "resolved": "8.4.2", "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", "dependencies": { - "Polly.Core": "8.4.2", - "System.Threading.RateLimiting": "8.0.0" + "Polly.Core": "8.4.2" } }, "Serilog": { @@ -542,9 +413,6 @@ "resolved": "10.0.0", "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", "Serilog": "4.3.0", "Serilog.Extensions.Logging": "10.0.0" } @@ -554,7 +422,6 @@ "resolved": "10.0.0", "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", "dependencies": { - "Microsoft.Extensions.Logging": "10.0.0", "Serilog": "4.2.0" } }, @@ -571,7 +438,6 @@ "resolved": "10.0.0", "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", "dependencies": { - "Microsoft.Extensions.Configuration.Binder": "10.0.0", "Microsoft.Extensions.DependencyModel": "10.0.0", "Serilog": "4.3.0" } @@ -596,10 +462,7 @@ "SQLitePCLRaw.core": { "type": "Transitive", "resolved": "2.1.11", - "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==", - "dependencies": { - "System.Memory": "4.5.3" - } + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" }, "SQLitePCLRaw.lib.e_sqlite3": { "type": "Transitive", @@ -614,27 +477,63 @@ "SQLitePCLRaw.core": "2.1.11" } }, - "System.Memory": { + "System.CodeDom": { "type": "Transitive", - "resolved": "4.5.3", - "contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA==" + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" }, - "System.Text.Json": { + "System.Composition": { "type": "Transitive", - "resolved": "8.0.5", - "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==" + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } }, - "System.Threading.RateLimiting": { + "System.Composition.AttributedModel": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } }, "werkr.common": { "type": "Project", "dependencies": { "Google.Protobuf": "[3.34.0, )", - "Microsoft.AspNetCore.Authorization": "[10.0.3, )", - "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", "Microsoft.IdentityModel.Tokens": "[8.16.0, )", "Werkr.Common.Configuration": "[1.0.0, )" } @@ -646,7 +545,6 @@ "type": "Project", "dependencies": { "Grpc.Net.Client": "[2.76.0, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.3, )", "System.Security.Cryptography.ProtectedData": "[10.0.3, )", "Werkr.Common": "[1.0.0, )", "Werkr.Data": "[1.0.0, )" @@ -678,8 +576,7 @@ "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", "dependencies": { "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" } }, "Google.Protobuf": { @@ -694,8 +591,7 @@ "resolved": "2.76.0", "contentHash": "K1oldmqw2+Gn69nGRzZLhqSiUZwelX1GrBu/cUl9wNf1C0uB61vFS6JcxUUv9P8VoUJhFsmV44JA6lI2EUt4xw==", "dependencies": { - "Grpc.Net.Common": "2.76.0", - "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + "Grpc.Net.Common": "2.76.0" } }, "Grpc.Net.ClientFactory": { @@ -704,20 +600,7 @@ "resolved": "2.76.0", "contentHash": "XI+kO69L9AV8B9N0UQOmH911r6MOEp9huHiavEsY56DJYuzJ9KAxNGy37dpV6CLbgCaN2uKmpOsZ9Pao6bmpVQ==", "dependencies": { - "Grpc.Net.Client": "2.76.0", - "Microsoft.Extensions.Http": "8.0.0" - } - }, - "Microsoft.AspNetCore.Authorization": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", - "dependencies": { - "Microsoft.AspNetCore.Metadata": "10.0.3", - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Grpc.Net.Client": "2.76.0" } }, "Microsoft.Data.Sqlite.Core": { @@ -736,9 +619,7 @@ "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", "dependencies": { "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3" } }, "Microsoft.EntityFrameworkCore.Sqlite": { @@ -748,48 +629,11 @@ "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", "dependencies": { "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", "SQLitePCLRaw.core": "2.1.11" } }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Configuration.Json": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" - } - }, - "Microsoft.Extensions.Hosting.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3" - } - }, "Microsoft.Extensions.Http.Resilience": { "type": "CentralTransitive", "requested": "[10.3.0, )", @@ -797,26 +641,15 @@ "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", "dependencies": { "Microsoft.Extensions.Http.Diagnostics": "10.3.0", - "Microsoft.Extensions.ObjectPool": "10.0.3", "Microsoft.Extensions.Resilience": "10.3.0" } }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" - } - }, "Microsoft.Extensions.ServiceDiscovery": { "type": "CentralTransitive", "requested": "[10.3.0, )", "resolved": "10.3.0", "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", "dependencies": { - "Microsoft.Extensions.Http": "10.0.3", "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" } }, @@ -826,7 +659,6 @@ "resolved": "8.16.0", "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", "Microsoft.IdentityModel.Logging": "8.16.0" } }, @@ -856,7 +688,6 @@ "resolved": "1.15.0", "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", "dependencies": { - "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", "OpenTelemetry": "1.15.0" } }, diff --git a/src/Werkr.AppHost/packages.lock.json b/src/Werkr.AppHost/packages.lock.json index b5ead1e..1a4ec3b 100644 --- a/src/Werkr.AppHost/packages.lock.json +++ b/src/Werkr.AppHost/packages.lock.json @@ -2,11 +2,11 @@ "version": 2, "dependencies": { "net10.0": { - "Aspire.Dashboard.Sdk.linux-x64": { + "Aspire.Dashboard.Sdk.win-arm64": { "type": "Direct", "requested": "[13.1.2, )", "resolved": "13.1.2", - "contentHash": "Y4U1uUGKGtbcEAuM6wXBKQ/Jo1T+57NNUzCTC5l+ZR2ibPjuW7xp4tw/qWdiwjNesN/BcCLmZ4c62jgVrnE9ww==" + "contentHash": "Sltiw6We9iia/isJItRmZ2ATfNJFPmEYiXFT5lzDL6Iu7vc/p/dge0SbUshY1SCLxWilwVCe0uv9WQyzoN+nWg==" }, "Aspire.Hosting.AppHost": { "type": "Direct", @@ -41,11 +41,11 @@ "System.IO.Hashing": "9.0.10" } }, - "Aspire.Hosting.Orchestration.linux-x64": { + "Aspire.Hosting.Orchestration.win-arm64": { "type": "Direct", "requested": "[13.1.2, )", "resolved": "13.1.2", - "contentHash": "Y4L2KiDOd95Pi4zlJ3DlXggq1eMbyAFZcBhNPiWsEsZFwvFkiQtcnz/0+VzOKplcCEE3KhoOIz5yZU1cBZtoEQ==" + "contentHash": "khjFSK2xbPR1S5sLG4XcDqyi/7TCQAyL8xkAmmFJt/aG0yFvVeqXXGokutz/YEEit0QpAeyuiPjWTpKIX70vPg==" }, "Aspire.Hosting.PostgreSQL": { "type": "Direct", @@ -498,8 +498,7 @@ "contentHash": "oDKOeKZ865I5X8qmU3IXMyrAnssYEiYWTobPGdrqubN3RtTzEHIv+D6fwhdcfrdhPJzHjCkK/ORztR/IsnmA6g==", "dependencies": { "Microsoft.VisualStudio.Threading.Only": "17.13.61", - "Microsoft.VisualStudio.Validation": "17.8.8", - "System.IO.Pipelines": "8.0.0" + "Microsoft.VisualStudio.Validation": "17.8.8" } }, "Newtonsoft.Json": { @@ -537,8 +536,7 @@ "Microsoft.VisualStudio.Threading.Only": "17.13.61", "Microsoft.VisualStudio.Validation": "17.8.8", "Nerdbank.Streams": "2.12.87", - "Newtonsoft.Json": "13.0.3", - "System.IO.Pipelines": "8.0.0" + "Newtonsoft.Json": "13.0.3" } }, "System.Diagnostics.EventLog": { @@ -551,11 +549,6 @@ "resolved": "9.0.10", "contentHash": "9gv5z71xaWWmcGEs4bXdreIhKp2kYLK2fvPK5gQkgnWMYvZ8ieaxKofDjxL3scZiEYfi/yW2nJTiKV2awcWEdA==" }, - "System.IO.Pipelines": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "FHNOatmUq0sqJOkTx+UF/9YK1f180cnW5FVqnQMvYUN0elp6wFzbtPSiqbo1/ru8ICp43JM1i7kKkk6GsNGHlA==" - }, "YamlDotNet": { "type": "Transitive", "resolved": "16.3.0", diff --git a/src/Werkr.Common/Rendering/AnsiHtmlConverter.cs b/src/Werkr.Common/Rendering/AnsiHtmlConverter.cs index 28239e2..cbcaabd 100644 --- a/src/Werkr.Common/Rendering/AnsiHtmlConverter.cs +++ b/src/Werkr.Common/Rendering/AnsiHtmlConverter.cs @@ -174,11 +174,9 @@ out string? bg /// The string to strip. /// The input with all ANSI escape sequences removed. public static string Strip( string input ) { - if (string.IsNullOrEmpty( input ) || !input.Contains( '\x1b' )) { - return input; - } - - return SgrPattern( ).Replace( + return string.IsNullOrEmpty( input ) || !input.Contains( '\x1b' ) + ? input + : SgrPattern( ).Replace( input, string.Empty ); diff --git a/src/Werkr.Core/Communication/AgentConnectionManager.cs b/src/Werkr.Core/Communication/AgentConnectionManager.cs index f582d48..92f03c2 100644 --- a/src/Werkr.Core/Communication/AgentConnectionManager.cs +++ b/src/Werkr.Core/Communication/AgentConnectionManager.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using Grpc.Core; using Grpc.Net.Client; using Microsoft.EntityFrameworkCore; diff --git a/src/Werkr.Core/Communication/CommandDispatcher.cs b/src/Werkr.Core/Communication/CommandDispatcher.cs index c14c638..8b17641 100644 --- a/src/Werkr.Core/Communication/CommandDispatcher.cs +++ b/src/Werkr.Core/Communication/CommandDispatcher.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using System.Security.Authentication; using System.Security.Cryptography; using System.Text.Json; diff --git a/src/Werkr.Core/Communication/CommandDispatcherException.cs b/src/Werkr.Core/Communication/CommandDispatcherException.cs index f8fe9ec..e4eb8d4 100644 --- a/src/Werkr.Core/Communication/CommandDispatcherException.cs +++ b/src/Werkr.Core/Communication/CommandDispatcherException.cs @@ -1,4 +1,4 @@ -namespace Werkr.Core.Communication; +namespace Werkr.Core.Communication; /// /// Typed exception thrown by to provide diff --git a/src/Werkr.Core/Communication/GrpcOutputReader.cs b/src/Werkr.Core/Communication/GrpcOutputReader.cs index 830859d..a913a86 100644 --- a/src/Werkr.Core/Communication/GrpcOutputReader.cs +++ b/src/Werkr.Core/Communication/GrpcOutputReader.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using Grpc.Core; diff --git a/src/Werkr.Core/Communication/ICommandDispatcher.cs b/src/Werkr.Core/Communication/ICommandDispatcher.cs index 0b1f1eb..0fc003a 100644 --- a/src/Werkr.Core/Communication/ICommandDispatcher.cs +++ b/src/Werkr.Core/Communication/ICommandDispatcher.cs @@ -1,4 +1,4 @@ -using Werkr.Common.Models; +using Werkr.Common.Models; using Werkr.Common.Models.Actions; namespace Werkr.Core.Communication; diff --git a/src/Werkr.Core/Communication/KeyRotationService.cs b/src/Werkr.Core/Communication/KeyRotationService.cs index fb791a0..06fc1d6 100644 --- a/src/Werkr.Core/Communication/KeyRotationService.cs +++ b/src/Werkr.Core/Communication/KeyRotationService.cs @@ -1,4 +1,4 @@ -using System.Security.Cryptography; +using System.Security.Cryptography; using Google.Protobuf; diff --git a/src/Werkr.Core/Communication/OperatorOutput.cs b/src/Werkr.Core/Communication/OperatorOutput.cs index 4b9653c..9450c54 100644 --- a/src/Werkr.Core/Communication/OperatorOutput.cs +++ b/src/Werkr.Core/Communication/OperatorOutput.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; namespace Werkr.Core.Communication; diff --git a/src/Werkr.Core/Communication/PayloadEncryptor.cs b/src/Werkr.Core/Communication/PayloadEncryptor.cs index 412e951..1daa0c3 100644 --- a/src/Werkr.Core/Communication/PayloadEncryptor.cs +++ b/src/Werkr.Core/Communication/PayloadEncryptor.cs @@ -1,4 +1,4 @@ -using Google.Protobuf; +using Google.Protobuf; using Werkr.Common.Protos; using Werkr.Core.Cryptography; diff --git a/src/Werkr.Core/Cryptography/EncryptionProvider.cs b/src/Werkr.Core/Cryptography/EncryptionProvider.cs index 5b2c053..0c728fa 100644 --- a/src/Werkr.Core/Cryptography/EncryptionProvider.cs +++ b/src/Werkr.Core/Cryptography/EncryptionProvider.cs @@ -1,4 +1,4 @@ -using System.Security.Cryptography; +using System.Security.Cryptography; using System.Text.Json; namespace Werkr.Core.Cryptography; diff --git a/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionData.cs b/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionData.cs index f1d1683..a51bbd5 100644 --- a/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionData.cs +++ b/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionData.cs @@ -1,4 +1,4 @@ -namespace Werkr.Core.Cryptography.KeyInfo; +namespace Werkr.Core.Cryptography.KeyInfo; /// /// Holds AES-GCM decryption data: the symmetric key and ordered chunk notes. diff --git a/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionNote.cs b/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionNote.cs index 2e7ff27..e04852d 100644 --- a/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionNote.cs +++ b/src/Werkr.Core/Cryptography/KeyInfo/AesGcmDecryptionNote.cs @@ -1,4 +1,4 @@ -namespace Werkr.Core.Cryptography.KeyInfo; +namespace Werkr.Core.Cryptography.KeyInfo; /// /// Holds nonce, tag, and order information for a single AES-GCM encrypted chunk. diff --git a/src/Werkr.Core/Cryptography/KeyInfo/RSAKeyPair.cs b/src/Werkr.Core/Cryptography/KeyInfo/RSAKeyPair.cs index 1344d90..51826a2 100644 --- a/src/Werkr.Core/Cryptography/KeyInfo/RSAKeyPair.cs +++ b/src/Werkr.Core/Cryptography/KeyInfo/RSAKeyPair.cs @@ -1,4 +1,4 @@ -using System.Security.Cryptography; +using System.Security.Cryptography; namespace Werkr.Core.Cryptography.KeyInfo; diff --git a/src/Werkr.Core/Cryptography/WerkrCryptoException.cs b/src/Werkr.Core/Cryptography/WerkrCryptoException.cs index 5c26d85..99f64db 100644 --- a/src/Werkr.Core/Cryptography/WerkrCryptoException.cs +++ b/src/Werkr.Core/Cryptography/WerkrCryptoException.cs @@ -1,4 +1,4 @@ -namespace Werkr.Core.Cryptography; +namespace Werkr.Core.Cryptography; /// /// Exception type for all Werkr cryptographic operation failures. diff --git a/src/Werkr.Core/Health/AgentHealthCheckService.cs b/src/Werkr.Core/Health/AgentHealthCheckService.cs index 998412a..ec72fac 100644 --- a/src/Werkr.Core/Health/AgentHealthCheckService.cs +++ b/src/Werkr.Core/Health/AgentHealthCheckService.cs @@ -1,4 +1,4 @@ -using Grpc.Core; +using Grpc.Core; using Grpc.Net.Client; using Microsoft.EntityFrameworkCore; diff --git a/src/Werkr.Core/Operators/IActionHandler.cs b/src/Werkr.Core/Operators/IActionHandler.cs index 77ff809..e1d76cd 100644 --- a/src/Werkr.Core/Operators/IActionHandler.cs +++ b/src/Werkr.Core/Operators/IActionHandler.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using System.Threading.Channels; using Werkr.Core.Communication; diff --git a/src/Werkr.Core/Operators/IActionOperator.cs b/src/Werkr.Core/Operators/IActionOperator.cs index 1e98f6c..dde1a7b 100644 --- a/src/Werkr.Core/Operators/IActionOperator.cs +++ b/src/Werkr.Core/Operators/IActionOperator.cs @@ -1,4 +1,4 @@ -using Werkr.Common.Models.Actions; +using Werkr.Common.Models.Actions; namespace Werkr.Core.Operators; diff --git a/src/Werkr.Core/Operators/IShellOperator.cs b/src/Werkr.Core/Operators/IShellOperator.cs index a8d2a0c..fe888a2 100644 --- a/src/Werkr.Core/Operators/IShellOperator.cs +++ b/src/Werkr.Core/Operators/IShellOperator.cs @@ -1,4 +1,4 @@ -namespace Werkr.Core.Operators; +namespace Werkr.Core.Operators; /// /// Interface for shell-based operators (PowerShell and system shell). diff --git a/src/Werkr.Core/Operators/OperatorExecution.cs b/src/Werkr.Core/Operators/OperatorExecution.cs index ec1c984..b43cbbf 100644 --- a/src/Werkr.Core/Operators/OperatorExecution.cs +++ b/src/Werkr.Core/Operators/OperatorExecution.cs @@ -1,4 +1,4 @@ -using Werkr.Core.Communication; +using Werkr.Core.Communication; namespace Werkr.Core.Operators; diff --git a/src/Werkr.Core/Registration/BundleExpirationService.cs b/src/Werkr.Core/Registration/BundleExpirationService.cs index eacb1f6..9eb0fe0 100644 --- a/src/Werkr.Core/Registration/BundleExpirationService.cs +++ b/src/Werkr.Core/Registration/BundleExpirationService.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; diff --git a/src/Werkr.Core/Registration/Models/AgentRegistrationResult.cs b/src/Werkr.Core/Registration/Models/AgentRegistrationResult.cs index de650d1..aea2fa0 100644 --- a/src/Werkr.Core/Registration/Models/AgentRegistrationResult.cs +++ b/src/Werkr.Core/Registration/Models/AgentRegistrationResult.cs @@ -1,4 +1,4 @@ -namespace Werkr.Core.Registration.Models; +namespace Werkr.Core.Registration.Models; /// /// Result returned to the Agent after a registration attempt completes. diff --git a/src/Werkr.Core/Registration/Models/RegistrationBundlePayload.cs b/src/Werkr.Core/Registration/Models/RegistrationBundlePayload.cs index 279262b..0646d4f 100644 --- a/src/Werkr.Core/Registration/Models/RegistrationBundlePayload.cs +++ b/src/Werkr.Core/Registration/Models/RegistrationBundlePayload.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using Werkr.Core.Cryptography; diff --git a/src/Werkr.Core/Registration/Models/RegistrationResponsePayload.cs b/src/Werkr.Core/Registration/Models/RegistrationResponsePayload.cs index 268584b..a1341d6 100644 --- a/src/Werkr.Core/Registration/Models/RegistrationResponsePayload.cs +++ b/src/Werkr.Core/Registration/Models/RegistrationResponsePayload.cs @@ -1,4 +1,4 @@ -namespace Werkr.Core.Registration.Models; +namespace Werkr.Core.Registration.Models; /// /// Data hybrid-encrypted in the Server's gRPC response during registration. diff --git a/src/Werkr.Core/Registration/RegistrationBundleGenerator.cs b/src/Werkr.Core/Registration/RegistrationBundleGenerator.cs index 9cf6d18..cf823ba 100644 --- a/src/Werkr.Core/Registration/RegistrationBundleGenerator.cs +++ b/src/Werkr.Core/Registration/RegistrationBundleGenerator.cs @@ -1,4 +1,4 @@ -using Werkr.Core.Cryptography; +using Werkr.Core.Cryptography; using Werkr.Core.Registration.Models; using Werkr.Data.Entities.Registration; diff --git a/src/Werkr.Core/Registration/RegistrationService.cs b/src/Werkr.Core/Registration/RegistrationService.cs index cfa2dd8..13f936c 100644 --- a/src/Werkr.Core/Registration/RegistrationService.cs +++ b/src/Werkr.Core/Registration/RegistrationService.cs @@ -1,4 +1,4 @@ -using System.Security.Cryptography; +using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; diff --git a/src/Werkr.Core/Scheduling/HolidayCalculator.cs b/src/Werkr.Core/Scheduling/HolidayCalculator.cs index c16d3fe..0145449 100644 --- a/src/Werkr.Core/Scheduling/HolidayCalculator.cs +++ b/src/Werkr.Core/Scheduling/HolidayCalculator.cs @@ -1,4 +1,4 @@ -using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Enums; using Werkr.Data.Entities.Schedule; namespace Werkr.Core.Scheduling; diff --git a/src/Werkr.Core/Scheduling/HolidayDateService.cs b/src/Werkr.Core/Scheduling/HolidayDateService.cs index c41b116..5d65c23 100644 --- a/src/Werkr.Core/Scheduling/HolidayDateService.cs +++ b/src/Werkr.Core/Scheduling/HolidayDateService.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Werkr.Data; diff --git a/src/Werkr.Core/Scheduling/ScheduleCalculator.cs b/src/Werkr.Core/Scheduling/ScheduleCalculator.cs index 46414c5..9b8333c 100644 --- a/src/Werkr.Core/Scheduling/ScheduleCalculator.cs +++ b/src/Werkr.Core/Scheduling/ScheduleCalculator.cs @@ -1,4 +1,4 @@ -using Werkr.Data.Calendar.Enums; +using Werkr.Data.Calendar.Enums; using Werkr.Data.Calendar.Extensions; using Werkr.Data.Calendar.Models; using Werkr.Data.Collections; diff --git a/src/Werkr.Core/Scheduling/ScheduleDescriptionBuilder.cs b/src/Werkr.Core/Scheduling/ScheduleDescriptionBuilder.cs index ee5f787..84cf7f3 100644 --- a/src/Werkr.Core/Scheduling/ScheduleDescriptionBuilder.cs +++ b/src/Werkr.Core/Scheduling/ScheduleDescriptionBuilder.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using Werkr.Data.Calendar.Enums; using Werkr.Data.Calendar.Extensions; diff --git a/src/Werkr.Core/Scheduling/ScheduleOccurrenceResult.cs b/src/Werkr.Core/Scheduling/ScheduleOccurrenceResult.cs index 8f5bf16..690a910 100644 --- a/src/Werkr.Core/Scheduling/ScheduleOccurrenceResult.cs +++ b/src/Werkr.Core/Scheduling/ScheduleOccurrenceResult.cs @@ -1,4 +1,4 @@ -namespace Werkr.Core.Scheduling; +namespace Werkr.Core.Scheduling; /// /// Result of schedule occurrence calculation, including both kept and suppressed occurrences. diff --git a/src/Werkr.Core/Scheduling/ScheduleService.cs b/src/Werkr.Core/Scheduling/ScheduleService.cs index 875698d..27e35b8 100644 --- a/src/Werkr.Core/Scheduling/ScheduleService.cs +++ b/src/Werkr.Core/Scheduling/ScheduleService.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Werkr.Data; diff --git a/src/Werkr.Core/Scheduling/SuppressedOccurrence.cs b/src/Werkr.Core/Scheduling/SuppressedOccurrence.cs index c742a35..902aea0 100644 --- a/src/Werkr.Core/Scheduling/SuppressedOccurrence.cs +++ b/src/Werkr.Core/Scheduling/SuppressedOccurrence.cs @@ -1,4 +1,4 @@ -namespace Werkr.Core.Scheduling; +namespace Werkr.Core.Scheduling; /// /// An occurrence that was suppressed (or, in allowlist mode, not matched) by a holiday calendar filter. diff --git a/src/Werkr.Core/Security/IFilePathResolver.cs b/src/Werkr.Core/Security/IFilePathResolver.cs index ff8f892..dbfd76f 100644 --- a/src/Werkr.Core/Security/IFilePathResolver.cs +++ b/src/Werkr.Core/Security/IFilePathResolver.cs @@ -1,4 +1,4 @@ -namespace Werkr.Core.Security; +namespace Werkr.Core.Security; /// /// Resolves and validates file-system paths against the configured allowlist. diff --git a/src/Werkr.Core/Security/ISecretStore.cs b/src/Werkr.Core/Security/ISecretStore.cs index 29f0483..0c0684a 100644 --- a/src/Werkr.Core/Security/ISecretStore.cs +++ b/src/Werkr.Core/Security/ISecretStore.cs @@ -1,4 +1,4 @@ -namespace Werkr.Core.Security; +namespace Werkr.Core.Security; /// /// Cross-platform abstraction for securely storing secrets in the OS credential store. diff --git a/src/Werkr.Core/Security/LinuxSecretStore.cs b/src/Werkr.Core/Security/LinuxSecretStore.cs index 8354639..fdc042b 100644 --- a/src/Werkr.Core/Security/LinuxSecretStore.cs +++ b/src/Werkr.Core/Security/LinuxSecretStore.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Runtime.Versioning; namespace Werkr.Core.Security; diff --git a/src/Werkr.Core/Security/MacOsSecretStore.cs b/src/Werkr.Core/Security/MacOsSecretStore.cs index 66639ed..2151a01 100644 --- a/src/Werkr.Core/Security/MacOsSecretStore.cs +++ b/src/Werkr.Core/Security/MacOsSecretStore.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Runtime.Versioning; namespace Werkr.Core.Security; diff --git a/src/Werkr.Core/Security/WindowsSecretStore.cs b/src/Werkr.Core/Security/WindowsSecretStore.cs index bfdfa5d..89eb813 100644 --- a/src/Werkr.Core/Security/WindowsSecretStore.cs +++ b/src/Werkr.Core/Security/WindowsSecretStore.cs @@ -1,4 +1,4 @@ -using System.Runtime.Versioning; +using System.Runtime.Versioning; using System.Security.Cryptography; namespace Werkr.Core.Security; diff --git a/src/Werkr.Core/Tasks/AgentResolver.cs b/src/Werkr.Core/Tasks/AgentResolver.cs index 404c6cb..7ce9a37 100644 --- a/src/Werkr.Core/Tasks/AgentResolver.cs +++ b/src/Werkr.Core/Tasks/AgentResolver.cs @@ -1,4 +1,4 @@ -using Grpc.Core; +using Grpc.Core; using Grpc.Net.Client; using Microsoft.EntityFrameworkCore; diff --git a/src/Werkr.Core/Tasks/JobExecutionService.cs b/src/Werkr.Core/Tasks/JobExecutionService.cs index b753d1c..0809434 100644 --- a/src/Werkr.Core/Tasks/JobExecutionService.cs +++ b/src/Werkr.Core/Tasks/JobExecutionService.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Text.Json; using Microsoft.EntityFrameworkCore; diff --git a/src/Werkr.Core/Tasks/JobOutputWriter.cs b/src/Werkr.Core/Tasks/JobOutputWriter.cs index bb83d25..a63abda 100644 --- a/src/Werkr.Core/Tasks/JobOutputWriter.cs +++ b/src/Werkr.Core/Tasks/JobOutputWriter.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Werkr.Common.Configuration; diff --git a/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs b/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs index 1b6c63c..d36e95e 100644 --- a/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs +++ b/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Werkr.Core.Communication; using Werkr.Data.Entities.Tasks; diff --git a/src/Werkr.Core/Tasks/TaskService.cs b/src/Werkr.Core/Tasks/TaskService.cs index b11df26..308e488 100644 --- a/src/Werkr.Core/Tasks/TaskService.cs +++ b/src/Werkr.Core/Tasks/TaskService.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Security.Cryptography; using Microsoft.EntityFrameworkCore; diff --git a/src/Werkr.Core/Workflows/ConditionEvaluator.cs b/src/Werkr.Core/Workflows/ConditionEvaluator.cs index 388f932..d7181be 100644 --- a/src/Werkr.Core/Workflows/ConditionEvaluator.cs +++ b/src/Werkr.Core/Workflows/ConditionEvaluator.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; diff --git a/src/Werkr.Core/Workflows/WorkflowExecutor.cs b/src/Werkr.Core/Workflows/WorkflowExecutor.cs index 75f3f8d..ebe02b4 100644 --- a/src/Werkr.Core/Workflows/WorkflowExecutor.cs +++ b/src/Werkr.Core/Workflows/WorkflowExecutor.cs @@ -1,4 +1,4 @@ -using System.Threading.Channels; +using System.Threading.Channels; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/src/Werkr.Core/Workflows/WorkflowRunTracker.cs b/src/Werkr.Core/Workflows/WorkflowRunTracker.cs index 8a6a19d..98fc878 100644 --- a/src/Werkr.Core/Workflows/WorkflowRunTracker.cs +++ b/src/Werkr.Core/Workflows/WorkflowRunTracker.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Threading.Channels; using Werkr.Common.Models; diff --git a/src/Werkr.Core/Workflows/WorkflowService.cs b/src/Werkr.Core/Workflows/WorkflowService.cs index 292c3d7..c6e364a 100644 --- a/src/Werkr.Core/Workflows/WorkflowService.cs +++ b/src/Werkr.Core/Workflows/WorkflowService.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Werkr.Data; diff --git a/src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.Designer.cs b/src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.Designer.cs new file mode 100644 index 0000000..51a9c6f --- /dev/null +++ b/src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.Designer.cs @@ -0,0 +1,528 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Werkr.Data.Identity; + +#nullable disable + +namespace Werkr.Data.Identity.Migrations.Postgres +{ + [DbContext(typeof(PostgresWerkrIdentityDbContext))] + [Migration("20260308034043_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("werkr_identity") + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", "werkr_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_role_claims_role_id"); + + b.ToTable("role_claims", "werkr_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claims_user_id"); + + b.ToTable("user_claims", "werkr_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_logins_user_id"); + + b.ToTable("user_logins", "werkr_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_user_roles_role_id"); + + b.ToTable("user_roles", "werkr_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_tokens"); + + b.ToTable("user_tokens", "werkr_identity"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by_user_id"); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_utc"); + + b.Property("ExpiresUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_utc"); + + b.Property("IsRevoked") + .HasColumnType("boolean") + .HasColumnName("is_revoked"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("key_hash"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("key_prefix"); + + b.Property("LastUsedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_utc"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_api_keys"); + + b.HasIndex("CreatedByUserId") + .HasDatabaseName("ix_api_keys_created_by_user_id"); + + b.HasIndex("KeyHash") + .IsUnique() + .HasDatabaseName("ix_api_keys_key_hash"); + + b.HasIndex("KeyPrefix") + .HasDatabaseName("ix_api_keys_key_prefix"); + + b.ToTable("api_keys", "werkr_identity"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.ConfigurationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowRegistration") + .HasColumnType("boolean") + .HasColumnName("allow_registration"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DefaultKeySize") + .HasColumnType("integer") + .HasColumnName("default_key_size"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_updated"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("integer") + .HasColumnName("polling_interval_seconds"); + + b.Property("RunDetailPollingIntervalSeconds") + .HasColumnType("integer") + .HasColumnName("run_detail_polling_interval_seconds"); + + b.Property("ServerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("server_name"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_config_settings"); + + b.ToTable("config_settings", "werkr_identity"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Permission") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("permission"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_role_permissions"); + + b.HasIndex("RoleId", "Permission") + .IsUnique() + .HasDatabaseName("ix_role_permissions_role_id_permission"); + + b.ToTable("role_permissions", "werkr_identity"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.WerkrUser", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ChangePassword") + .HasColumnType("boolean") + .HasColumnName("change_password"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("LastLoginUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_utc"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("Requires2FA") + .HasColumnType("boolean") + .HasColumnName("requires2fa"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", "werkr_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_claims_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_claims_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_logins_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_roles_role_id"); + + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_tokens_users_user_id"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.ApiKey", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_keys_users_created_by_user_id"); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.RolePermission", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_permissions_roles_role_id"); + + b.Navigation("Role"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.cs b/src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.cs new file mode 100644 index 0000000..2767fa9 --- /dev/null +++ b/src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.cs @@ -0,0 +1,340 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Werkr.Data.Identity.Migrations.Postgres; +/// +public partial class InitialCreate : Migration { + /// + protected override void Up( MigrationBuilder migrationBuilder ) { + _ = migrationBuilder.EnsureSchema( + name: "werkr_identity" ); + + _ = migrationBuilder.CreateTable( + name: "config_settings", + schema: "werkr_identity", + columns: table => new { + id = table.Column( type: "uuid", nullable: false ), + default_key_size = table.Column( type: "integer", nullable: false ), + server_name = table.Column( type: "character varying(200)", maxLength: 200, nullable: false ), + allow_registration = table.Column( type: "boolean", nullable: false ), + polling_interval_seconds = table.Column( type: "integer", nullable: false ), + run_detail_polling_interval_seconds = table.Column( type: "integer", nullable: false ), + created = table.Column( type: "timestamp with time zone", nullable: false ), + last_updated = table.Column( type: "timestamp with time zone", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_config_settings", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "roles", + schema: "werkr_identity", + columns: table => new { + id = table.Column( type: "text", nullable: false ), + name = table.Column( type: "character varying(256)", maxLength: 256, nullable: true ), + normalized_name = table.Column( type: "character varying(256)", maxLength: 256, nullable: true ), + concurrency_stamp = table.Column( type: "text", nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_roles", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "users", + schema: "werkr_identity", + columns: table => new { + id = table.Column( type: "text", nullable: false ), + name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), + enabled = table.Column( type: "boolean", nullable: false ), + change_password = table.Column( type: "boolean", nullable: false ), + requires2fa = table.Column( type: "boolean", nullable: false ), + last_login_utc = table.Column( type: "timestamp with time zone", nullable: true ), + user_name = table.Column( type: "character varying(256)", maxLength: 256, nullable: true ), + normalized_user_name = table.Column( type: "character varying(256)", maxLength: 256, nullable: true ), + email = table.Column( type: "character varying(256)", maxLength: 256, nullable: true ), + normalized_email = table.Column( type: "character varying(256)", maxLength: 256, nullable: true ), + email_confirmed = table.Column( type: "boolean", nullable: false ), + password_hash = table.Column( type: "text", nullable: true ), + security_stamp = table.Column( type: "text", nullable: true ), + concurrency_stamp = table.Column( type: "text", nullable: true ), + phone_number = table.Column( type: "text", nullable: true ), + phone_number_confirmed = table.Column( type: "boolean", nullable: false ), + two_factor_enabled = table.Column( type: "boolean", nullable: false ), + lockout_end = table.Column( type: "timestamp with time zone", nullable: true ), + lockout_enabled = table.Column( type: "boolean", nullable: false ), + access_failed_count = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_users", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "role_claims", + schema: "werkr_identity", + columns: table => new { + id = table.Column( type: "integer", nullable: false ) + .Annotation( "Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn ), + role_id = table.Column( type: "text", nullable: false ), + claim_type = table.Column( type: "text", nullable: true ), + claim_value = table.Column( type: "text", nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_role_claims", x => x.id ); + _ = table.ForeignKey( + name: "fk_role_claims_roles_role_id", + column: x => x.role_id, + principalSchema: "werkr_identity", + principalTable: "roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "role_permissions", + schema: "werkr_identity", + columns: table => new { + id = table.Column( type: "bigint", nullable: false ) + .Annotation( "Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn ), + role_id = table.Column( type: "text", nullable: false ), + permission = table.Column( type: "character varying(64)", maxLength: 64, nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_role_permissions", x => x.id ); + _ = table.ForeignKey( + name: "fk_role_permissions_roles_role_id", + column: x => x.role_id, + principalSchema: "werkr_identity", + principalTable: "roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "api_keys", + schema: "werkr_identity", + columns: table => new { + id = table.Column( type: "uuid", nullable: false ), + key_hash = table.Column( type: "character varying(128)", maxLength: 128, nullable: false ), + key_prefix = table.Column( type: "character varying(16)", maxLength: 16, nullable: false ), + name = table.Column( type: "character varying(200)", maxLength: 200, nullable: false ), + role = table.Column( type: "character varying(64)", maxLength: 64, nullable: false ), + created_by_user_id = table.Column( type: "text", nullable: false ), + created_utc = table.Column( type: "timestamp with time zone", nullable: false ), + expires_utc = table.Column( type: "timestamp with time zone", nullable: true ), + is_revoked = table.Column( type: "boolean", nullable: false ), + last_used_utc = table.Column( type: "timestamp with time zone", nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_api_keys", x => x.id ); + _ = table.ForeignKey( + name: "fk_api_keys_users_created_by_user_id", + column: x => x.created_by_user_id, + principalSchema: "werkr_identity", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "user_claims", + schema: "werkr_identity", + columns: table => new { + id = table.Column( type: "integer", nullable: false ) + .Annotation( "Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn ), + user_id = table.Column( type: "text", nullable: false ), + claim_type = table.Column( type: "text", nullable: true ), + claim_value = table.Column( type: "text", nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_user_claims", x => x.id ); + _ = table.ForeignKey( + name: "fk_user_claims_users_user_id", + column: x => x.user_id, + principalSchema: "werkr_identity", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "user_logins", + schema: "werkr_identity", + columns: table => new { + login_provider = table.Column( type: "character varying(128)", maxLength: 128, nullable: false ), + provider_key = table.Column( type: "character varying(128)", maxLength: 128, nullable: false ), + provider_display_name = table.Column( type: "text", nullable: true ), + user_id = table.Column( type: "text", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_user_logins", x => new { x.login_provider, x.provider_key } ); + _ = table.ForeignKey( + name: "fk_user_logins_users_user_id", + column: x => x.user_id, + principalSchema: "werkr_identity", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "user_roles", + schema: "werkr_identity", + columns: table => new { + user_id = table.Column( type: "text", nullable: false ), + role_id = table.Column( type: "text", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_user_roles", x => new { x.user_id, x.role_id } ); + _ = table.ForeignKey( + name: "fk_user_roles_roles_role_id", + column: x => x.role_id, + principalSchema: "werkr_identity", + principalTable: "roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + _ = table.ForeignKey( + name: "fk_user_roles_users_user_id", + column: x => x.user_id, + principalSchema: "werkr_identity", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "user_tokens", + schema: "werkr_identity", + columns: table => new { + user_id = table.Column( type: "text", nullable: false ), + login_provider = table.Column( type: "character varying(128)", maxLength: 128, nullable: false ), + name = table.Column( type: "character varying(128)", maxLength: 128, nullable: false ), + value = table.Column( type: "text", nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_user_tokens", x => new { x.user_id, x.login_provider, x.name } ); + _ = table.ForeignKey( + name: "fk_user_tokens_users_user_id", + column: x => x.user_id, + principalSchema: "werkr_identity", + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateIndex( + name: "ix_api_keys_created_by_user_id", + schema: "werkr_identity", + table: "api_keys", + column: "created_by_user_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_api_keys_key_hash", + schema: "werkr_identity", + table: "api_keys", + column: "key_hash", + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "ix_api_keys_key_prefix", + schema: "werkr_identity", + table: "api_keys", + column: "key_prefix" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_role_claims_role_id", + schema: "werkr_identity", + table: "role_claims", + column: "role_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_role_permissions_role_id_permission", + schema: "werkr_identity", + table: "role_permissions", + columns: new[] { "role_id", "permission" }, + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "RoleNameIndex", + schema: "werkr_identity", + table: "roles", + column: "normalized_name", + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "ix_user_claims_user_id", + schema: "werkr_identity", + table: "user_claims", + column: "user_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_user_logins_user_id", + schema: "werkr_identity", + table: "user_logins", + column: "user_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_user_roles_role_id", + schema: "werkr_identity", + table: "user_roles", + column: "role_id" ); + + _ = migrationBuilder.CreateIndex( + name: "EmailIndex", + schema: "werkr_identity", + table: "users", + column: "normalized_email" ); + + _ = migrationBuilder.CreateIndex( + name: "UserNameIndex", + schema: "werkr_identity", + table: "users", + column: "normalized_user_name", + unique: true ); + } + + /// + protected override void Down( MigrationBuilder migrationBuilder ) { + _ = migrationBuilder.DropTable( + name: "api_keys", + schema: "werkr_identity" ); + + _ = migrationBuilder.DropTable( + name: "config_settings", + schema: "werkr_identity" ); + + _ = migrationBuilder.DropTable( + name: "role_claims", + schema: "werkr_identity" ); + + _ = migrationBuilder.DropTable( + name: "role_permissions", + schema: "werkr_identity" ); + + _ = migrationBuilder.DropTable( + name: "user_claims", + schema: "werkr_identity" ); + + _ = migrationBuilder.DropTable( + name: "user_logins", + schema: "werkr_identity" ); + + _ = migrationBuilder.DropTable( + name: "user_roles", + schema: "werkr_identity" ); + + _ = migrationBuilder.DropTable( + name: "user_tokens", + schema: "werkr_identity" ); + + _ = migrationBuilder.DropTable( + name: "roles", + schema: "werkr_identity" ); + + _ = migrationBuilder.DropTable( + name: "users", + schema: "werkr_identity" ); + } +} diff --git a/src/Werkr.Data.Identity/Migrations/Postgres/PostgresWerkrIdentityDbContextModelSnapshot.cs b/src/Werkr.Data.Identity/Migrations/Postgres/PostgresWerkrIdentityDbContextModelSnapshot.cs new file mode 100644 index 0000000..584ddeb --- /dev/null +++ b/src/Werkr.Data.Identity/Migrations/Postgres/PostgresWerkrIdentityDbContextModelSnapshot.cs @@ -0,0 +1,525 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Werkr.Data.Identity; + +#nullable disable + +namespace Werkr.Data.Identity.Migrations.Postgres +{ + [DbContext(typeof(PostgresWerkrIdentityDbContext))] + partial class PostgresWerkrIdentityDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("werkr_identity") + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", "werkr_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_role_claims_role_id"); + + b.ToTable("role_claims", "werkr_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claims_user_id"); + + b.ToTable("user_claims", "werkr_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_logins_user_id"); + + b.ToTable("user_logins", "werkr_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_user_roles_role_id"); + + b.ToTable("user_roles", "werkr_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_tokens"); + + b.ToTable("user_tokens", "werkr_identity"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_by_user_id"); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_utc"); + + b.Property("ExpiresUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_utc"); + + b.Property("IsRevoked") + .HasColumnType("boolean") + .HasColumnName("is_revoked"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("key_hash"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("key_prefix"); + + b.Property("LastUsedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_utc"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_api_keys"); + + b.HasIndex("CreatedByUserId") + .HasDatabaseName("ix_api_keys_created_by_user_id"); + + b.HasIndex("KeyHash") + .IsUnique() + .HasDatabaseName("ix_api_keys_key_hash"); + + b.HasIndex("KeyPrefix") + .HasDatabaseName("ix_api_keys_key_prefix"); + + b.ToTable("api_keys", "werkr_identity"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.ConfigurationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowRegistration") + .HasColumnType("boolean") + .HasColumnName("allow_registration"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DefaultKeySize") + .HasColumnType("integer") + .HasColumnName("default_key_size"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_updated"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("integer") + .HasColumnName("polling_interval_seconds"); + + b.Property("RunDetailPollingIntervalSeconds") + .HasColumnType("integer") + .HasColumnName("run_detail_polling_interval_seconds"); + + b.Property("ServerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("server_name"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_config_settings"); + + b.ToTable("config_settings", "werkr_identity"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Permission") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("permission"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_role_permissions"); + + b.HasIndex("RoleId", "Permission") + .IsUnique() + .HasDatabaseName("ix_role_permissions_role_id_permission"); + + b.ToTable("role_permissions", "werkr_identity"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.WerkrUser", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("ChangePassword") + .HasColumnType("boolean") + .HasColumnName("change_password"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("LastLoginUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_login_utc"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("Requires2FA") + .HasColumnType("boolean") + .HasColumnName("requires2fa"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", "werkr_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_claims_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_claims_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_logins_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_roles_role_id"); + + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_tokens_users_user_id"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.ApiKey", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_keys_users_created_by_user_id"); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.RolePermission", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_permissions_roles_role_id"); + + b.Navigation("Role"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.Designer.cs b/src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.Designer.cs new file mode 100644 index 0000000..f2f6c38 --- /dev/null +++ b/src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.Designer.cs @@ -0,0 +1,516 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Werkr.Data.Identity; + +#nullable disable + +namespace Werkr.Data.Identity.Migrations.Sqlite +{ + [DbContext(typeof(SqliteWerkrIdentityDbContext))] + [Migration("20260308034120_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("ClaimType") + .HasColumnType("TEXT") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("TEXT") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_role_claims_role_id"); + + b.ToTable("role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("ClaimType") + .HasColumnType("TEXT") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("TEXT") + .HasColumnName("claim_value"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claims_user_id"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_logins_user_id"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("TEXT") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_user_roles_role_id"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("TEXT") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_tokens"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_by_user_id"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT") + .HasColumnName("created_utc"); + + b.Property("ExpiresUtc") + .HasColumnType("TEXT") + .HasColumnName("expires_utc"); + + b.Property("IsRevoked") + .HasColumnType("INTEGER") + .HasColumnName("is_revoked"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("key_hash"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("key_prefix"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT") + .HasColumnName("last_used_utc"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_api_keys"); + + b.HasIndex("CreatedByUserId") + .HasDatabaseName("ix_api_keys_created_by_user_id"); + + b.HasIndex("KeyHash") + .IsUnique() + .HasDatabaseName("ix_api_keys_key_hash"); + + b.HasIndex("KeyPrefix") + .HasDatabaseName("ix_api_keys_key_prefix"); + + b.ToTable("api_keys", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.ConfigurationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AllowRegistration") + .HasColumnType("INTEGER") + .HasColumnName("allow_registration"); + + b.Property("Created") + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("DefaultKeySize") + .HasColumnType("INTEGER") + .HasColumnName("default_key_size"); + + b.Property("LastUpdated") + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER") + .HasColumnName("polling_interval_seconds"); + + b.Property("RunDetailPollingIntervalSeconds") + .HasColumnType("INTEGER") + .HasColumnName("run_detail_polling_interval_seconds"); + + b.Property("ServerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("server_name"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_config_settings"); + + b.ToTable("config_settings", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Permission") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("permission"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_role_permissions"); + + b.HasIndex("RoleId", "Permission") + .IsUnique() + .HasDatabaseName("ix_role_permissions_role_id_permission"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.WerkrUser", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER") + .HasColumnName("access_failed_count"); + + b.Property("ChangePassword") + .HasColumnType("INTEGER") + .HasColumnName("change_password"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER") + .HasColumnName("email_confirmed"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastLoginUtc") + .HasColumnType("TEXT") + .HasColumnName("last_login_utc"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT") + .HasColumnName("lockout_end"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("TEXT") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER") + .HasColumnName("phone_number_confirmed"); + + b.Property("Requires2FA") + .HasColumnType("INTEGER") + .HasColumnName("requires2fa"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_claims_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_claims_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_logins_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_roles_role_id"); + + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_tokens_users_user_id"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.ApiKey", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_keys_users_created_by_user_id"); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.RolePermission", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_permissions_roles_role_id"); + + b.Navigation("Role"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.cs b/src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.cs new file mode 100644 index 0000000..2397b44 --- /dev/null +++ b/src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.cs @@ -0,0 +1,297 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Werkr.Data.Identity.Migrations.Sqlite; +/// +public partial class InitialCreate : Migration { + /// + protected override void Up( MigrationBuilder migrationBuilder ) { + _ = migrationBuilder.CreateTable( + name: "config_settings", + columns: table => new { + id = table.Column( type: "TEXT", nullable: false ), + default_key_size = table.Column( type: "INTEGER", nullable: false ), + server_name = table.Column( type: "TEXT", maxLength: 200, nullable: false ), + allow_registration = table.Column( type: "INTEGER", nullable: false ), + polling_interval_seconds = table.Column( type: "INTEGER", nullable: false ), + run_detail_polling_interval_seconds = table.Column( type: "INTEGER", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_config_settings", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "roles", + columns: table => new { + id = table.Column( type: "TEXT", nullable: false ), + name = table.Column( type: "TEXT", maxLength: 256, nullable: true ), + normalized_name = table.Column( type: "TEXT", maxLength: 256, nullable: true ), + concurrency_stamp = table.Column( type: "TEXT", nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_roles", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "users", + columns: table => new { + id = table.Column( type: "TEXT", nullable: false ), + name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), + enabled = table.Column( type: "INTEGER", nullable: false ), + change_password = table.Column( type: "INTEGER", nullable: false ), + requires2fa = table.Column( type: "INTEGER", nullable: false ), + last_login_utc = table.Column( type: "TEXT", nullable: true ), + user_name = table.Column( type: "TEXT", maxLength: 256, nullable: true ), + normalized_user_name = table.Column( type: "TEXT", maxLength: 256, nullable: true ), + email = table.Column( type: "TEXT", maxLength: 256, nullable: true ), + normalized_email = table.Column( type: "TEXT", maxLength: 256, nullable: true ), + email_confirmed = table.Column( type: "INTEGER", nullable: false ), + password_hash = table.Column( type: "TEXT", nullable: true ), + security_stamp = table.Column( type: "TEXT", nullable: true ), + concurrency_stamp = table.Column( type: "TEXT", nullable: true ), + phone_number = table.Column( type: "TEXT", nullable: true ), + phone_number_confirmed = table.Column( type: "INTEGER", nullable: false ), + two_factor_enabled = table.Column( type: "INTEGER", nullable: false ), + lockout_end = table.Column( type: "TEXT", nullable: true ), + lockout_enabled = table.Column( type: "INTEGER", nullable: false ), + access_failed_count = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_users", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "role_claims", + columns: table => new { + id = table.Column( type: "INTEGER", nullable: false ) + .Annotation( "Sqlite:Autoincrement", true ), + role_id = table.Column( type: "TEXT", nullable: false ), + claim_type = table.Column( type: "TEXT", nullable: true ), + claim_value = table.Column( type: "TEXT", nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_role_claims", x => x.id ); + _ = table.ForeignKey( + name: "fk_role_claims_roles_role_id", + column: x => x.role_id, + principalTable: "roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "role_permissions", + columns: table => new { + id = table.Column( type: "INTEGER", nullable: false ) + .Annotation( "Sqlite:Autoincrement", true ), + role_id = table.Column( type: "TEXT", nullable: false ), + permission = table.Column( type: "TEXT", maxLength: 64, nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_role_permissions", x => x.id ); + _ = table.ForeignKey( + name: "fk_role_permissions_roles_role_id", + column: x => x.role_id, + principalTable: "roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "api_keys", + columns: table => new { + id = table.Column( type: "TEXT", nullable: false ), + key_hash = table.Column( type: "TEXT", maxLength: 128, nullable: false ), + key_prefix = table.Column( type: "TEXT", maxLength: 16, nullable: false ), + name = table.Column( type: "TEXT", maxLength: 200, nullable: false ), + role = table.Column( type: "TEXT", maxLength: 64, nullable: false ), + created_by_user_id = table.Column( type: "TEXT", nullable: false ), + created_utc = table.Column( type: "TEXT", nullable: false ), + expires_utc = table.Column( type: "TEXT", nullable: true ), + is_revoked = table.Column( type: "INTEGER", nullable: false ), + last_used_utc = table.Column( type: "TEXT", nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_api_keys", x => x.id ); + _ = table.ForeignKey( + name: "fk_api_keys_users_created_by_user_id", + column: x => x.created_by_user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "user_claims", + columns: table => new { + id = table.Column( type: "INTEGER", nullable: false ) + .Annotation( "Sqlite:Autoincrement", true ), + user_id = table.Column( type: "TEXT", nullable: false ), + claim_type = table.Column( type: "TEXT", nullable: true ), + claim_value = table.Column( type: "TEXT", nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_user_claims", x => x.id ); + _ = table.ForeignKey( + name: "fk_user_claims_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "user_logins", + columns: table => new { + login_provider = table.Column( type: "TEXT", maxLength: 128, nullable: false ), + provider_key = table.Column( type: "TEXT", maxLength: 128, nullable: false ), + provider_display_name = table.Column( type: "TEXT", nullable: true ), + user_id = table.Column( type: "TEXT", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_user_logins", x => new { x.login_provider, x.provider_key } ); + _ = table.ForeignKey( + name: "fk_user_logins_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "user_roles", + columns: table => new { + user_id = table.Column( type: "TEXT", nullable: false ), + role_id = table.Column( type: "TEXT", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_user_roles", x => new { x.user_id, x.role_id } ); + _ = table.ForeignKey( + name: "fk_user_roles_roles_role_id", + column: x => x.role_id, + principalTable: "roles", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + _ = table.ForeignKey( + name: "fk_user_roles_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "user_tokens", + columns: table => new { + user_id = table.Column( type: "TEXT", nullable: false ), + login_provider = table.Column( type: "TEXT", maxLength: 128, nullable: false ), + name = table.Column( type: "TEXT", maxLength: 128, nullable: false ), + value = table.Column( type: "TEXT", nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_user_tokens", x => new { x.user_id, x.login_provider, x.name } ); + _ = table.ForeignKey( + name: "fk_user_tokens_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateIndex( + name: "ix_api_keys_created_by_user_id", + table: "api_keys", + column: "created_by_user_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_api_keys_key_hash", + table: "api_keys", + column: "key_hash", + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "ix_api_keys_key_prefix", + table: "api_keys", + column: "key_prefix" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_role_claims_role_id", + table: "role_claims", + column: "role_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_role_permissions_role_id_permission", + table: "role_permissions", + columns: new[] { "role_id", "permission" }, + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "roles", + column: "normalized_name", + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "ix_user_claims_user_id", + table: "user_claims", + column: "user_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_user_logins_user_id", + table: "user_logins", + column: "user_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_user_roles_role_id", + table: "user_roles", + column: "role_id" ); + + _ = migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "users", + column: "normalized_email" ); + + _ = migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "users", + column: "normalized_user_name", + unique: true ); + } + + /// + protected override void Down( MigrationBuilder migrationBuilder ) { + _ = migrationBuilder.DropTable( + name: "api_keys" ); + + _ = migrationBuilder.DropTable( + name: "config_settings" ); + + _ = migrationBuilder.DropTable( + name: "role_claims" ); + + _ = migrationBuilder.DropTable( + name: "role_permissions" ); + + _ = migrationBuilder.DropTable( + name: "user_claims" ); + + _ = migrationBuilder.DropTable( + name: "user_logins" ); + + _ = migrationBuilder.DropTable( + name: "user_roles" ); + + _ = migrationBuilder.DropTable( + name: "user_tokens" ); + + _ = migrationBuilder.DropTable( + name: "roles" ); + + _ = migrationBuilder.DropTable( + name: "users" ); + } +} diff --git a/src/Werkr.Data.Identity/Migrations/Sqlite/SqliteWerkrIdentityDbContextModelSnapshot.cs b/src/Werkr.Data.Identity/Migrations/Sqlite/SqliteWerkrIdentityDbContextModelSnapshot.cs new file mode 100644 index 0000000..fb4db83 --- /dev/null +++ b/src/Werkr.Data.Identity/Migrations/Sqlite/SqliteWerkrIdentityDbContextModelSnapshot.cs @@ -0,0 +1,513 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Werkr.Data.Identity; + +#nullable disable + +namespace Werkr.Data.Identity.Migrations.Sqlite +{ + [DbContext(typeof(SqliteWerkrIdentityDbContext))] + partial class SqliteWerkrIdentityDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT") + .HasColumnName("concurrency_stamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("normalized_name"); + + b.HasKey("Id") + .HasName("pk_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("ClaimType") + .HasColumnType("TEXT") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("TEXT") + .HasColumnName("claim_value"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_role_claims_role_id"); + + b.ToTable("role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("ClaimType") + .HasColumnType("TEXT") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("TEXT") + .HasColumnName("claim_value"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claims_user_id"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_logins_user_id"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("RoleId") + .HasColumnType("TEXT") + .HasColumnName("role_id"); + + b.HasKey("UserId", "RoleId") + .HasName("pk_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_user_roles_role_id"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("TEXT") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_tokens"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_by_user_id"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT") + .HasColumnName("created_utc"); + + b.Property("ExpiresUtc") + .HasColumnType("TEXT") + .HasColumnName("expires_utc"); + + b.Property("IsRevoked") + .HasColumnType("INTEGER") + .HasColumnName("is_revoked"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("key_hash"); + + b.Property("KeyPrefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasColumnName("key_prefix"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT") + .HasColumnName("last_used_utc"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("role"); + + b.HasKey("Id") + .HasName("pk_api_keys"); + + b.HasIndex("CreatedByUserId") + .HasDatabaseName("ix_api_keys_created_by_user_id"); + + b.HasIndex("KeyHash") + .IsUnique() + .HasDatabaseName("ix_api_keys_key_hash"); + + b.HasIndex("KeyPrefix") + .HasDatabaseName("ix_api_keys_key_prefix"); + + b.ToTable("api_keys", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.ConfigurationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AllowRegistration") + .HasColumnType("INTEGER") + .HasColumnName("allow_registration"); + + b.Property("Created") + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("DefaultKeySize") + .HasColumnType("INTEGER") + .HasColumnName("default_key_size"); + + b.Property("LastUpdated") + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER") + .HasColumnName("polling_interval_seconds"); + + b.Property("RunDetailPollingIntervalSeconds") + .HasColumnType("INTEGER") + .HasColumnName("run_detail_polling_interval_seconds"); + + b.Property("ServerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("server_name"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_config_settings"); + + b.ToTable("config_settings", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Permission") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .HasColumnName("permission"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_role_permissions"); + + b.HasIndex("RoleId", "Permission") + .IsUnique() + .HasDatabaseName("ix_role_permissions_role_id_permission"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.WerkrUser", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER") + .HasColumnName("access_failed_count"); + + b.Property("ChangePassword") + .HasColumnType("INTEGER") + .HasColumnName("change_password"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT") + .HasColumnName("concurrency_stamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER") + .HasColumnName("email_confirmed"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastLoginUtc") + .HasColumnType("TEXT") + .HasColumnName("last_login_utc"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT") + .HasColumnName("lockout_end"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasColumnType("TEXT") + .HasColumnName("password_hash"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER") + .HasColumnName("phone_number_confirmed"); + + b.Property("Requires2FA") + .HasColumnType("INTEGER") + .HasColumnName("requires2fa"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT") + .HasColumnName("security_stamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER") + .HasColumnName("two_factor_enabled"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_claims_roles_role_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_claims_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_logins_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_roles_role_id"); + + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_roles_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_tokens_users_user_id"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.ApiKey", b => + { + b.HasOne("Werkr.Data.Identity.Entities.WerkrUser", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_keys_users_created_by_user_id"); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Werkr.Data.Identity.Entities.RolePermission", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_role_permissions_roles_role_id"); + + b.Navigation("Role"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.Designer.cs b/src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.Designer.cs new file mode 100644 index 0000000..34256d8 --- /dev/null +++ b/src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.Designer.cs @@ -0,0 +1,1422 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Werkr.Data; + +#nullable disable + +namespace Werkr.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresWerkrDbContext))] + [Migration("20260308033431_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("werkr") + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Werkr.Data.Entities.Registration.RegisteredConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActiveKeyId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("active_key_id"); + + b.Property("AllowedPaths") + .IsRequired() + .HasColumnType("text") + .HasColumnName("allowed_paths"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("connection_name"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("EnforceAllowlist") + .HasColumnType("boolean") + .HasColumnName("enforce_allowlist"); + + b.Property("InboundApiKeyHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("inbound_api_key_hash"); + + b.Property("IsServer") + .HasColumnType("boolean") + .HasColumnName("is_server"); + + b.Property("LastSeen") + .HasColumnType("text") + .HasColumnName("last_seen"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("LocalPrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("local_private_key"); + + b.Property("LocalPublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("local_public_key"); + + b.Property("OutboundApiKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("outbound_api_key"); + + b.Property("PreviousKeyId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("previous_key_id"); + + b.Property("PreviousSharedKey") + .HasColumnType("text") + .HasColumnName("previous_shared_key"); + + b.Property("RemotePublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("remote_public_key"); + + b.Property("RemoteUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("remote_url"); + + b.Property("SharedKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("shared_key"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tags"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_registered_connections"); + + b.HasIndex("ConnectionName") + .HasDatabaseName("ix_registered_connections_connection_name"); + + b.HasIndex("RemoteUrl") + .HasDatabaseName("ix_registered_connections_remote_url"); + + b.ToTable("registered_connections", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Registration.RegistrationBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowedPaths") + .IsRequired() + .HasColumnType("text") + .HasColumnName("allowed_paths"); + + b.Property("BundleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("bundle_id"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("connection_name"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("ExpiresAt") + .IsRequired() + .HasColumnType("text") + .HasColumnName("expires_at"); + + b.Property("KeySize") + .HasColumnType("integer") + .HasColumnName("key_size"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("ServerPrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("server_private_key"); + + b.Property("ServerPublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("server_public_key"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_registration_bundles"); + + b.HasIndex("BundleId") + .IsUnique() + .HasDatabaseName("ix_registration_bundles_bundle_id"); + + b.ToTable("registration_bundles", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DailyRecurrence", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("DayInterval") + .HasColumnType("integer") + .HasColumnName("day_interval"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_daily_recurrence"); + + b.ToTable("daily_recurrence", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DbSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("StopTaskAfterMinutes") + .HasColumnType("bigint") + .HasColumnName("stop_task_after_minutes"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_schedules"); + + b.ToTable("schedules", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ExpirationDateTimeInfo", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Time") + .HasColumnType("time without time zone") + .HasColumnName("time"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("time_zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_schedule_expiration"); + + b.ToTable("schedule_expiration", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayCalendar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedUtc") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_utc"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("description"); + + b.Property("IsSystemCalendar") + .HasColumnType("boolean") + .HasColumnName("is_system_calendar"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("UpdatedUtc") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_utc"); + + b.HasKey("Id") + .HasName("pk_holiday_calendars"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_holiday_calendars_name"); + + b.ToTable("holiday_calendars", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayDate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("HolidayCalendarId") + .HasColumnType("uuid") + .HasColumnName("holiday_calendar_id"); + + b.Property("HolidayRuleId") + .HasColumnType("bigint") + .HasColumnName("holiday_rule_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("WindowEnd") + .HasColumnType("time without time zone") + .HasColumnName("window_end"); + + b.Property("WindowStart") + .HasColumnType("time without time zone") + .HasColumnName("window_start"); + + b.Property("WindowTimeZoneId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("window_time_zone_id"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_holiday_dates"); + + b.HasIndex("HolidayRuleId") + .HasDatabaseName("ix_holiday_dates_holiday_rule_id"); + + b.HasIndex("HolidayCalendarId", "Date") + .IsUnique() + .HasDatabaseName("ix_holiday_dates_holiday_calendar_id_date"); + + b.ToTable("holiday_dates", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("Day") + .HasColumnType("integer") + .HasColumnName("day"); + + b.Property("DayOfWeek") + .HasColumnType("integer") + .HasColumnName("day_of_week"); + + b.Property("HolidayCalendarId") + .HasColumnType("uuid") + .HasColumnName("holiday_calendar_id"); + + b.Property("Month") + .HasColumnType("integer") + .HasColumnName("month"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ObservanceRule") + .IsRequired() + .HasColumnType("text") + .HasColumnName("observance_rule"); + + b.Property("RuleType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rule_type"); + + b.Property("WeekNumber") + .HasColumnType("integer") + .HasColumnName("week_number"); + + b.Property("WindowEnd") + .HasColumnType("time without time zone") + .HasColumnName("window_end"); + + b.Property("WindowStart") + .HasColumnType("time without time zone") + .HasColumnName("window_start"); + + b.Property("WindowTimeZoneId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("window_time_zone_id"); + + b.Property("YearEnd") + .HasColumnType("integer") + .HasColumnName("year_end"); + + b.Property("YearStart") + .HasColumnType("integer") + .HasColumnName("year_start"); + + b.HasKey("Id") + .HasName("pk_holiday_rules"); + + b.HasIndex("HolidayCalendarId") + .HasDatabaseName("ix_holiday_rules_holiday_calendar_id"); + + b.ToTable("holiday_rules", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.MonthlyRecurrence", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("DayNumbers") + .HasColumnType("text") + .HasColumnName("day_numbers"); + + b.Property("DaysOfWeek") + .HasColumnType("integer") + .HasColumnName("days_of_week"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("MonthsOfYear") + .HasColumnType("integer") + .HasColumnName("months_of_year"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WeekNumber") + .HasColumnType("integer") + .HasColumnName("week_number"); + + b.HasKey("ScheduleId") + .HasName("pk_monthly_recurrence"); + + b.ToTable("monthly_recurrence", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("CalendarName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("calendar_name"); + + b.Property("CreatedUtc") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_utc"); + + b.Property("HolidayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("holiday_name"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("mode"); + + b.Property("OccurrenceUtcTime") + .IsRequired() + .HasColumnType("text") + .HasColumnName("occurrence_utc_time"); + + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.HasKey("Id") + .HasName("pk_schedule_audit_log"); + + b.HasIndex("ScheduleId", "OccurrenceUtcTime") + .HasDatabaseName("ix_schedule_audit_log_schedule_id_occurrence_utc_time"); + + b.ToTable("schedule_audit_log", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleHolidayCalendar", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("HolidayCalendarId") + .HasColumnType("uuid") + .HasColumnName("holiday_calendar_id"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("mode"); + + b.HasKey("ScheduleId", "HolidayCalendarId") + .HasName("pk_schedule_holiday_calendars"); + + b.HasIndex("HolidayCalendarId") + .HasDatabaseName("ix_schedule_holiday_calendars_holiday_calendar_id"); + + b.HasIndex("ScheduleId") + .IsUnique() + .HasDatabaseName("ix_schedule_holiday_calendars_schedule_id"); + + b.ToTable("schedule_holiday_calendars", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleRepeatOptions", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("RepeatDurationMinutes") + .HasColumnType("integer") + .HasColumnName("repeat_duration_minutes"); + + b.Property("RepeatIntervalMinutes") + .HasColumnType("integer") + .HasColumnName("repeat_interval_minutes"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_schedule_repeat_options"); + + b.ToTable("schedule_repeat_options", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.StartDateTimeInfo", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Time") + .HasColumnType("time without time zone") + .HasColumnName("time"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("time_zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_schedule_start_datetimeinfo"); + + b.ToTable("schedule_start_datetimeinfo", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.WeeklyRecurrence", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("DaysOfWeek") + .HasColumnType("integer") + .HasColumnName("days_of_week"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WeekInterval") + .HasColumnType("integer") + .HasColumnName("week_interval"); + + b.HasKey("ScheduleId") + .HasName("pk_weekly_recurrence"); + + b.ToTable("weekly_recurrence", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AgentConnectionId") + .HasColumnType("uuid") + .HasColumnName("agent_connection_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("EndTime") + .HasColumnType("text") + .HasColumnName("end_time"); + + b.Property("ErrorCategory") + .IsRequired() + .HasColumnType("text") + .HasColumnName("error_category"); + + b.Property("ExitCode") + .HasColumnType("integer") + .HasColumnName("exit_code"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Output") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("output"); + + b.Property("OutputPath") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("output_path"); + + b.Property("RuntimeSeconds") + .HasColumnType("double precision") + .HasColumnName("runtime_seconds"); + + b.Property("StartTime") + .IsRequired() + .HasColumnType("text") + .HasColumnName("start_time"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("TaskId") + .HasColumnType("bigint") + .HasColumnName("task_id"); + + b.Property("TaskSnapshot") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("task_snapshot"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WorkflowRunId") + .HasColumnType("uuid") + .HasColumnName("workflow_run_id"); + + b.HasKey("Id") + .HasName("pk_jobs"); + + b.HasIndex("AgentConnectionId") + .HasDatabaseName("ix_jobs_agent_connection_id"); + + b.HasIndex("TaskId") + .HasDatabaseName("ix_jobs_task_id"); + + b.HasIndex("WorkflowRunId") + .HasDatabaseName("ix_jobs_workflow_run_id"); + + b.ToTable("jobs", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionParameters") + .HasColumnType("text") + .HasColumnName("action_parameters"); + + b.Property("ActionSubType") + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("action_sub_type"); + + b.Property("ActionType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action_type"); + + b.Property("Arguments") + .HasColumnType("text") + .HasColumnName("arguments"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("content"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("SuccessCriteria") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("success_criteria"); + + b.Property("SyncIntervalMinutes") + .HasColumnType("integer") + .HasColumnName("sync_interval_minutes"); + + b.Property("TargetTags") + .IsRequired() + .HasColumnType("text") + .HasColumnName("target_tags"); + + b.Property("TimeoutMinutes") + .HasColumnType("bigint") + .HasColumnName("timeout_minutes"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WorkflowId") + .HasColumnType("bigint") + .HasColumnName("workflow_id"); + + b.HasKey("Id") + .HasName("pk_tasks"); + + b.HasIndex("WorkflowId") + .HasDatabaseName("ix_tasks_workflow_id"); + + b.ToTable("tasks", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_workflows"); + + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_workflows_schedule_id"); + + b.ToTable("workflows", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("EndTime") + .HasColumnType("text") + .HasColumnName("end_time"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("StartTime") + .IsRequired() + .HasColumnType("text") + .HasColumnName("start_time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WorkflowId") + .HasColumnType("bigint") + .HasColumnName("workflow_id"); + + b.HasKey("Id") + .HasName("pk_workflow_runs"); + + b.HasIndex("WorkflowId") + .HasDatabaseName("ix_workflow_runs_workflow_id"); + + b.ToTable("workflow_runs", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentConnectionIdOverride") + .HasColumnType("uuid") + .HasColumnName("agent_connection_id_override"); + + b.Property("ConditionExpression") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("condition_expression"); + + b.Property("ControlStatement") + .IsRequired() + .HasColumnType("text") + .HasColumnName("control_statement"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("DependencyMode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("dependency_mode"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("MaxIterations") + .HasColumnType("integer") + .HasColumnName("max_iterations"); + + b.Property("Order") + .HasColumnType("integer") + .HasColumnName("order"); + + b.Property("TaskId") + .HasColumnType("bigint") + .HasColumnName("task_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WorkflowId") + .HasColumnType("bigint") + .HasColumnName("workflow_id"); + + b.HasKey("Id") + .HasName("pk_workflow_steps"); + + b.HasIndex("AgentConnectionIdOverride") + .HasDatabaseName("ix_workflow_steps_agent_connection_id_override"); + + b.HasIndex("TaskId") + .HasDatabaseName("ix_workflow_steps_task_id"); + + b.HasIndex("WorkflowId") + .HasDatabaseName("ix_workflow_steps_workflow_id"); + + b.ToTable("workflow_steps", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStepDependency", b => + { + b.Property("StepId") + .HasColumnType("bigint") + .HasColumnName("step_id"); + + b.Property("DependsOnStepId") + .HasColumnType("bigint") + .HasColumnName("depends_on_step_id"); + + b.HasKey("StepId", "DependsOnStepId") + .HasName("pk_workflow_step_dependencies"); + + b.HasIndex("DependsOnStepId") + .HasDatabaseName("ix_workflow_step_dependencies_depends_on_step_id"); + + b.ToTable("workflow_step_dependencies", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DailyRecurrence", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("DailyRecurrence") + .HasForeignKey("Werkr.Data.Entities.Schedule.DailyRecurrence", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_daily_recurrence_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ExpirationDateTimeInfo", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("Expiration") + .HasForeignKey("Werkr.Data.Entities.Schedule.ExpirationDateTimeInfo", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_expiration_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayDate", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.HolidayCalendar", "Calendar") + .WithMany("Dates") + .HasForeignKey("HolidayCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_holiday_dates_holiday_calendars_holiday_calendar_id"); + + b.HasOne("Werkr.Data.Entities.Schedule.HolidayRule", "GeneratedByRule") + .WithMany("GeneratedDates") + .HasForeignKey("HolidayRuleId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_holiday_dates_holiday_rules_holiday_rule_id"); + + b.Navigation("Calendar"); + + b.Navigation("GeneratedByRule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayRule", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.HolidayCalendar", "Calendar") + .WithMany("Rules") + .HasForeignKey("HolidayCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_holiday_rules_holiday_calendars_holiday_calendar_id"); + + b.Navigation("Calendar"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.MonthlyRecurrence", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("MonthlyRecurrence") + .HasForeignKey("Werkr.Data.Entities.Schedule.MonthlyRecurrence", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_monthly_recurrence_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleAuditLog", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_audit_log_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleHolidayCalendar", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.HolidayCalendar", "Calendar") + .WithMany("ScheduleLinks") + .HasForeignKey("HolidayCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_holiday_calendars_holiday_calendars_holiday_calend"); + + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("HolidayCalendarLink") + .HasForeignKey("Werkr.Data.Entities.Schedule.ScheduleHolidayCalendar", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_holiday_calendars_schedules_schedule_id"); + + b.Navigation("Calendar"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleRepeatOptions", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("RepeatOptions") + .HasForeignKey("Werkr.Data.Entities.Schedule.ScheduleRepeatOptions", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_repeat_options_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.StartDateTimeInfo", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("StartDateTime") + .HasForeignKey("Werkr.Data.Entities.Schedule.StartDateTimeInfo", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_start_datetimeinfo_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.WeeklyRecurrence", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("WeeklyRecurrence") + .HasForeignKey("Werkr.Data.Entities.Schedule.WeeklyRecurrence", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_weekly_recurrence_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => + { + b.HasOne("Werkr.Data.Entities.Registration.RegisteredConnection", "AgentConnection") + .WithMany() + .HasForeignKey("AgentConnectionId") + .HasConstraintName("fk_jobs_registered_connections_agent_connection_id"); + + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_jobs_tasks_task_id"); + + b.HasOne("Werkr.Data.Entities.Workflows.WorkflowRun", "WorkflowRun") + .WithMany("Jobs") + .HasForeignKey("WorkflowRunId") + .HasConstraintName("fk_jobs_workflow_runs_workflow_run_id"); + + b.Navigation("AgentConnection"); + + b.Navigation("Task"); + + b.Navigation("WorkflowRun"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrTask", b => + { + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Tasks") + .HasForeignKey("WorkflowId") + .HasConstraintName("fk_tasks_workflows_workflow_id"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .HasConstraintName("fk_workflows_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + { + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Runs") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_runs_workflows_workflow_id"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => + { + b.HasOne("Werkr.Data.Entities.Registration.RegisteredConnection", "AgentConnectionOverride") + .WithMany() + .HasForeignKey("AgentConnectionIdOverride") + .HasConstraintName("fk_workflow_steps_registered_connections_agent_connection_id_o"); + + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_steps_tasks_task_id"); + + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Steps") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_steps_workflows_workflow_id"); + + b.Navigation("AgentConnectionOverride"); + + b.Navigation("Task"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStepDependency", b => + { + b.HasOne("Werkr.Data.Entities.Workflows.WorkflowStep", "DependsOnStep") + .WithMany("Dependents") + .HasForeignKey("DependsOnStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_workflow_step_dependencies_workflow_steps_depends_on_step_id"); + + b.HasOne("Werkr.Data.Entities.Workflows.WorkflowStep", "Step") + .WithMany("Dependencies") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_step_dependencies_workflow_steps_step_id"); + + b.Navigation("DependsOnStep"); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DbSchedule", b => + { + b.Navigation("DailyRecurrence"); + + b.Navigation("Expiration"); + + b.Navigation("HolidayCalendarLink"); + + b.Navigation("MonthlyRecurrence"); + + b.Navigation("RepeatOptions"); + + b.Navigation("StartDateTime"); + + b.Navigation("WeeklyRecurrence"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayCalendar", b => + { + b.Navigation("Dates"); + + b.Navigation("Rules"); + + b.Navigation("ScheduleLinks"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayRule", b => + { + b.Navigation("GeneratedDates"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + { + b.Navigation("Runs"); + + b.Navigation("Steps"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + { + b.Navigation("Jobs"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => + { + b.Navigation("Dependencies"); + + b.Navigation("Dependents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.cs b/src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.cs new file mode 100644 index 0000000..50d52ad --- /dev/null +++ b/src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.cs @@ -0,0 +1,745 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Werkr.Data.Migrations.Postgres; +/// +public partial class InitialCreate : Migration { + /// + protected override void Up( MigrationBuilder migrationBuilder ) { + _ = migrationBuilder.EnsureSchema( + name: "werkr" ); + + _ = migrationBuilder.CreateTable( + name: "holiday_calendars", + schema: "werkr", + columns: table => new { + id = table.Column( type: "uuid", nullable: false ), + name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), + description = table.Column( type: "character varying(1024)", maxLength: 1024, nullable: false ), + is_system_calendar = table.Column( type: "boolean", nullable: false ), + created_utc = table.Column( type: "text", nullable: false ), + updated_utc = table.Column( type: "text", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_holiday_calendars", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "registered_connections", + schema: "werkr", + columns: table => new { + id = table.Column( type: "uuid", nullable: false ), + connection_name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), + remote_url = table.Column( type: "character varying(2048)", maxLength: 2048, nullable: false ), + local_public_key = table.Column( type: "text", nullable: false ), + local_private_key = table.Column( type: "text", nullable: false ), + remote_public_key = table.Column( type: "text", nullable: false ), + outbound_api_key = table.Column( type: "character varying(512)", maxLength: 512, nullable: false ), + inbound_api_key_hash = table.Column( type: "character varying(512)", maxLength: 512, nullable: false ), + shared_key = table.Column( type: "text", nullable: false ), + previous_shared_key = table.Column( type: "text", nullable: true ), + active_key_id = table.Column( type: "character varying(128)", maxLength: 128, nullable: true ), + previous_key_id = table.Column( type: "character varying(128)", maxLength: 128, nullable: true ), + is_server = table.Column( type: "boolean", nullable: false ), + status = table.Column( type: "text", nullable: false ), + last_seen = table.Column( type: "text", nullable: true ), + tags = table.Column( type: "text", nullable: false ), + allowed_paths = table.Column( type: "text", nullable: false ), + enforce_allowlist = table.Column( type: "boolean", nullable: false ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_registered_connections", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "registration_bundles", + schema: "werkr", + columns: table => new { + id = table.Column( type: "uuid", nullable: false ), + connection_name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), + server_public_key = table.Column( type: "text", nullable: false ), + server_private_key = table.Column( type: "text", nullable: false ), + bundle_id = table.Column( type: "text", nullable: false ), + status = table.Column( type: "text", nullable: false ), + expires_at = table.Column( type: "text", nullable: false ), + key_size = table.Column( type: "integer", nullable: false ), + tags = table.Column( type: "text[]", nullable: false ), + allowed_paths = table.Column( type: "text", nullable: false ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_registration_bundles", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "schedules", + schema: "werkr", + columns: table => new { + id = table.Column( type: "uuid", nullable: false ), + name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), + stop_task_after_minutes = table.Column( type: "bigint", nullable: false ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_schedules", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "holiday_rules", + schema: "werkr", + columns: table => new { + id = table.Column( type: "bigint", nullable: false ) + .Annotation( "Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn ), + holiday_calendar_id = table.Column( type: "uuid", nullable: false ), + name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), + rule_type = table.Column( type: "text", nullable: false ), + month = table.Column( type: "integer", nullable: true ), + day = table.Column( type: "integer", nullable: true ), + day_of_week = table.Column( type: "integer", nullable: true ), + week_number = table.Column( type: "integer", nullable: true ), + window_start = table.Column( type: "time without time zone", nullable: true ), + window_end = table.Column( type: "time without time zone", nullable: true ), + window_time_zone_id = table.Column( type: "character varying(128)", maxLength: 128, nullable: true ), + observance_rule = table.Column( type: "text", nullable: false ), + year_start = table.Column( type: "integer", nullable: true ), + year_end = table.Column( type: "integer", nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_holiday_rules", x => x.id ); + _ = table.ForeignKey( + name: "fk_holiday_rules_holiday_calendars_holiday_calendar_id", + column: x => x.holiday_calendar_id, + principalSchema: "werkr", + principalTable: "holiday_calendars", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "daily_recurrence", + schema: "werkr", + columns: table => new { + schedule_id = table.Column( type: "uuid", nullable: false ), + day_interval = table.Column( type: "integer", nullable: false ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_daily_recurrence", x => x.schedule_id ); + _ = table.ForeignKey( + name: "fk_daily_recurrence_schedules_schedule_id", + column: x => x.schedule_id, + principalSchema: "werkr", + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "monthly_recurrence", + schema: "werkr", + columns: table => new { + schedule_id = table.Column( type: "uuid", nullable: false ), + day_numbers = table.Column( type: "text", nullable: true ), + months_of_year = table.Column( type: "integer", nullable: false ), + week_number = table.Column( type: "integer", nullable: true ), + days_of_week = table.Column( type: "integer", nullable: true ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_monthly_recurrence", x => x.schedule_id ); + _ = table.ForeignKey( + name: "fk_monthly_recurrence_schedules_schedule_id", + column: x => x.schedule_id, + principalSchema: "werkr", + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "schedule_audit_log", + schema: "werkr", + columns: table => new { + id = table.Column( type: "bigint", nullable: false ) + .Annotation( "Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn ), + schedule_id = table.Column( type: "uuid", nullable: false ), + occurrence_utc_time = table.Column( type: "text", nullable: false ), + calendar_name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), + holiday_name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), + mode = table.Column( type: "text", nullable: false ), + created_utc = table.Column( type: "text", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_schedule_audit_log", x => x.id ); + _ = table.ForeignKey( + name: "fk_schedule_audit_log_schedules_schedule_id", + column: x => x.schedule_id, + principalSchema: "werkr", + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "schedule_expiration", + schema: "werkr", + columns: table => new { + schedule_id = table.Column( type: "uuid", nullable: false ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ), + date = table.Column( type: "date", nullable: false ), + time = table.Column( type: "time without time zone", nullable: false ), + time_zone = table.Column( type: "text", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_schedule_expiration", x => x.schedule_id ); + _ = table.ForeignKey( + name: "fk_schedule_expiration_schedules_schedule_id", + column: x => x.schedule_id, + principalSchema: "werkr", + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "schedule_holiday_calendars", + schema: "werkr", + columns: table => new { + schedule_id = table.Column( type: "uuid", nullable: false ), + holiday_calendar_id = table.Column( type: "uuid", nullable: false ), + mode = table.Column( type: "text", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_schedule_holiday_calendars", x => new { x.schedule_id, x.holiday_calendar_id } ); + _ = table.ForeignKey( + name: "fk_schedule_holiday_calendars_holiday_calendars_holiday_calend", + column: x => x.holiday_calendar_id, + principalSchema: "werkr", + principalTable: "holiday_calendars", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + _ = table.ForeignKey( + name: "fk_schedule_holiday_calendars_schedules_schedule_id", + column: x => x.schedule_id, + principalSchema: "werkr", + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "schedule_repeat_options", + schema: "werkr", + columns: table => new { + schedule_id = table.Column( type: "uuid", nullable: false ), + repeat_interval_minutes = table.Column( type: "integer", nullable: false ), + repeat_duration_minutes = table.Column( type: "integer", nullable: false ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_schedule_repeat_options", x => x.schedule_id ); + _ = table.ForeignKey( + name: "fk_schedule_repeat_options_schedules_schedule_id", + column: x => x.schedule_id, + principalSchema: "werkr", + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "schedule_start_datetimeinfo", + schema: "werkr", + columns: table => new { + schedule_id = table.Column( type: "uuid", nullable: false ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ), + date = table.Column( type: "date", nullable: false ), + time = table.Column( type: "time without time zone", nullable: false ), + time_zone = table.Column( type: "text", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_schedule_start_datetimeinfo", x => x.schedule_id ); + _ = table.ForeignKey( + name: "fk_schedule_start_datetimeinfo_schedules_schedule_id", + column: x => x.schedule_id, + principalSchema: "werkr", + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "weekly_recurrence", + schema: "werkr", + columns: table => new { + schedule_id = table.Column( type: "uuid", nullable: false ), + week_interval = table.Column( type: "integer", nullable: false ), + days_of_week = table.Column( type: "integer", nullable: false ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_weekly_recurrence", x => x.schedule_id ); + _ = table.ForeignKey( + name: "fk_weekly_recurrence_schedules_schedule_id", + column: x => x.schedule_id, + principalSchema: "werkr", + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "workflows", + schema: "werkr", + columns: table => new { + id = table.Column( type: "bigint", nullable: false ) + .Annotation( "Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn ), + name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), + description = table.Column( type: "character varying(2000)", maxLength: 2000, nullable: false ), + enabled = table.Column( type: "boolean", nullable: false ), + schedule_id = table.Column( type: "uuid", nullable: true ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflows", x => x.id ); + _ = table.ForeignKey( + name: "fk_workflows_schedules_schedule_id", + column: x => x.schedule_id, + principalSchema: "werkr", + principalTable: "schedules", + principalColumn: "id" ); + } ); + + _ = migrationBuilder.CreateTable( + name: "holiday_dates", + schema: "werkr", + columns: table => new { + id = table.Column( type: "bigint", nullable: false ) + .Annotation( "Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn ), + holiday_calendar_id = table.Column( type: "uuid", nullable: false ), + holiday_rule_id = table.Column( type: "bigint", nullable: true ), + date = table.Column( type: "date", nullable: false ), + name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), + year = table.Column( type: "integer", nullable: false ), + window_start = table.Column( type: "time without time zone", nullable: true ), + window_end = table.Column( type: "time without time zone", nullable: true ), + window_time_zone_id = table.Column( type: "character varying(128)", maxLength: 128, nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_holiday_dates", x => x.id ); + _ = table.ForeignKey( + name: "fk_holiday_dates_holiday_calendars_holiday_calendar_id", + column: x => x.holiday_calendar_id, + principalSchema: "werkr", + principalTable: "holiday_calendars", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + _ = table.ForeignKey( + name: "fk_holiday_dates_holiday_rules_holiday_rule_id", + column: x => x.holiday_rule_id, + principalSchema: "werkr", + principalTable: "holiday_rules", + principalColumn: "id", + onDelete: ReferentialAction.SetNull ); + } ); + + _ = migrationBuilder.CreateTable( + name: "tasks", + schema: "werkr", + columns: table => new { + id = table.Column( type: "bigint", nullable: false ) + .Annotation( "Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn ), + name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), + description = table.Column( type: "character varying(2000)", maxLength: 2000, nullable: false ), + action_type = table.Column( type: "text", nullable: false ), + schedule_id = table.Column( type: "uuid", nullable: true ), + workflow_id = table.Column( type: "bigint", nullable: true ), + content = table.Column( type: "character varying(8000)", maxLength: 8000, nullable: false ), + arguments = table.Column( type: "text", nullable: true ), + target_tags = table.Column( type: "text", nullable: false ), + enabled = table.Column( type: "boolean", nullable: false ), + timeout_minutes = table.Column( type: "bigint", nullable: true ), + sync_interval_minutes = table.Column( type: "integer", nullable: false ), + success_criteria = table.Column( type: "character varying(500)", maxLength: 500, nullable: true ), + action_sub_type = table.Column( type: "character varying(30)", maxLength: 30, nullable: true ), + action_parameters = table.Column( type: "text", nullable: true ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_tasks", x => x.id ); + _ = table.ForeignKey( + name: "fk_tasks_workflows_workflow_id", + column: x => x.workflow_id, + principalSchema: "werkr", + principalTable: "workflows", + principalColumn: "id" ); + } ); + + _ = migrationBuilder.CreateTable( + name: "workflow_runs", + schema: "werkr", + columns: table => new { + id = table.Column( type: "uuid", nullable: false ), + workflow_id = table.Column( type: "bigint", nullable: false ), + start_time = table.Column( type: "text", nullable: false ), + end_time = table.Column( type: "text", nullable: true ), + status = table.Column( type: "text", nullable: false ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflow_runs", x => x.id ); + _ = table.ForeignKey( + name: "fk_workflow_runs_workflows_workflow_id", + column: x => x.workflow_id, + principalSchema: "werkr", + principalTable: "workflows", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "workflow_steps", + schema: "werkr", + columns: table => new { + id = table.Column( type: "bigint", nullable: false ) + .Annotation( "Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn ), + workflow_id = table.Column( type: "bigint", nullable: false ), + task_id = table.Column( type: "bigint", nullable: false ), + order = table.Column( type: "integer", nullable: false ), + control_statement = table.Column( type: "text", nullable: false ), + condition_expression = table.Column( type: "character varying(2000)", maxLength: 2000, nullable: true ), + max_iterations = table.Column( type: "integer", nullable: false ), + agent_connection_id_override = table.Column( type: "uuid", nullable: true ), + dependency_mode = table.Column( type: "text", nullable: false ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflow_steps", x => x.id ); + _ = table.ForeignKey( + name: "fk_workflow_steps_registered_connections_agent_connection_id_o", + column: x => x.agent_connection_id_override, + principalSchema: "werkr", + principalTable: "registered_connections", + principalColumn: "id" ); + _ = table.ForeignKey( + name: "fk_workflow_steps_tasks_task_id", + column: x => x.task_id, + principalSchema: "werkr", + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + _ = table.ForeignKey( + name: "fk_workflow_steps_workflows_workflow_id", + column: x => x.workflow_id, + principalSchema: "werkr", + principalTable: "workflows", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "jobs", + schema: "werkr", + columns: table => new { + id = table.Column( type: "uuid", nullable: false ), + task_id = table.Column( type: "bigint", nullable: false ), + task_snapshot = table.Column( type: "character varying(8000)", maxLength: 8000, nullable: false ), + runtime_seconds = table.Column( type: "double precision", nullable: false ), + start_time = table.Column( type: "text", nullable: false ), + end_time = table.Column( type: "text", nullable: true ), + success = table.Column( type: "boolean", nullable: false ), + agent_connection_id = table.Column( type: "uuid", nullable: true ), + exit_code = table.Column( type: "integer", nullable: true ), + error_category = table.Column( type: "text", nullable: false ), + output = table.Column( type: "character varying(2000)", maxLength: 2000, nullable: true ), + output_path = table.Column( type: "character varying(512)", maxLength: 512, nullable: true ), + workflow_run_id = table.Column( type: "uuid", nullable: true ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_jobs", x => x.id ); + _ = table.ForeignKey( + name: "fk_jobs_registered_connections_agent_connection_id", + column: x => x.agent_connection_id, + principalSchema: "werkr", + principalTable: "registered_connections", + principalColumn: "id" ); + _ = table.ForeignKey( + name: "fk_jobs_tasks_task_id", + column: x => x.task_id, + principalSchema: "werkr", + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + _ = table.ForeignKey( + name: "fk_jobs_workflow_runs_workflow_run_id", + column: x => x.workflow_run_id, + principalSchema: "werkr", + principalTable: "workflow_runs", + principalColumn: "id" ); + } ); + + _ = migrationBuilder.CreateTable( + name: "workflow_step_dependencies", + schema: "werkr", + columns: table => new { + step_id = table.Column( type: "bigint", nullable: false ), + depends_on_step_id = table.Column( type: "bigint", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflow_step_dependencies", x => new { x.step_id, x.depends_on_step_id } ); + _ = table.ForeignKey( + name: "fk_workflow_step_dependencies_workflow_steps_depends_on_step_id", + column: x => x.depends_on_step_id, + principalSchema: "werkr", + principalTable: "workflow_steps", + principalColumn: "id", + onDelete: ReferentialAction.Restrict ); + _ = table.ForeignKey( + name: "fk_workflow_step_dependencies_workflow_steps_step_id", + column: x => x.step_id, + principalSchema: "werkr", + principalTable: "workflow_steps", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateIndex( + name: "ix_holiday_calendars_name", + schema: "werkr", + table: "holiday_calendars", + column: "name", + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "ix_holiday_dates_holiday_calendar_id_date", + schema: "werkr", + table: "holiday_dates", + columns: new[] { "holiday_calendar_id", "date" }, + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "ix_holiday_dates_holiday_rule_id", + schema: "werkr", + table: "holiday_dates", + column: "holiday_rule_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_holiday_rules_holiday_calendar_id", + schema: "werkr", + table: "holiday_rules", + column: "holiday_calendar_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_jobs_agent_connection_id", + schema: "werkr", + table: "jobs", + column: "agent_connection_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_jobs_task_id", + schema: "werkr", + table: "jobs", + column: "task_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_jobs_workflow_run_id", + schema: "werkr", + table: "jobs", + column: "workflow_run_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_registered_connections_connection_name", + schema: "werkr", + table: "registered_connections", + column: "connection_name" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_registered_connections_remote_url", + schema: "werkr", + table: "registered_connections", + column: "remote_url" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_registration_bundles_bundle_id", + schema: "werkr", + table: "registration_bundles", + column: "bundle_id", + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "ix_schedule_audit_log_schedule_id_occurrence_utc_time", + schema: "werkr", + table: "schedule_audit_log", + columns: new[] { "schedule_id", "occurrence_utc_time" } ); + + _ = migrationBuilder.CreateIndex( + name: "ix_schedule_holiday_calendars_holiday_calendar_id", + schema: "werkr", + table: "schedule_holiday_calendars", + column: "holiday_calendar_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_schedule_holiday_calendars_schedule_id", + schema: "werkr", + table: "schedule_holiday_calendars", + column: "schedule_id", + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "ix_tasks_workflow_id", + schema: "werkr", + table: "tasks", + column: "workflow_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_workflow_runs_workflow_id", + schema: "werkr", + table: "workflow_runs", + column: "workflow_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_workflow_step_dependencies_depends_on_step_id", + schema: "werkr", + table: "workflow_step_dependencies", + column: "depends_on_step_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_workflow_steps_agent_connection_id_override", + schema: "werkr", + table: "workflow_steps", + column: "agent_connection_id_override" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_workflow_steps_task_id", + schema: "werkr", + table: "workflow_steps", + column: "task_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_workflow_steps_workflow_id", + schema: "werkr", + table: "workflow_steps", + column: "workflow_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_workflows_schedule_id", + schema: "werkr", + table: "workflows", + column: "schedule_id" ); + } + + /// + protected override void Down( MigrationBuilder migrationBuilder ) { + _ = migrationBuilder.DropTable( + name: "daily_recurrence", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "holiday_dates", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "jobs", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "monthly_recurrence", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "registration_bundles", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "schedule_audit_log", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "schedule_expiration", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "schedule_holiday_calendars", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "schedule_repeat_options", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "schedule_start_datetimeinfo", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "weekly_recurrence", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "workflow_step_dependencies", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "holiday_rules", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "workflow_runs", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "workflow_steps", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "holiday_calendars", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "registered_connections", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "tasks", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "workflows", + schema: "werkr" ); + + _ = migrationBuilder.DropTable( + name: "schedules", + schema: "werkr" ); + } +} diff --git a/src/Werkr.Data/Migrations/Postgres/PostgresWerkrDbContextModelSnapshot.cs b/src/Werkr.Data/Migrations/Postgres/PostgresWerkrDbContextModelSnapshot.cs new file mode 100644 index 0000000..9c23dfd --- /dev/null +++ b/src/Werkr.Data/Migrations/Postgres/PostgresWerkrDbContextModelSnapshot.cs @@ -0,0 +1,1419 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Werkr.Data; + +#nullable disable + +namespace Werkr.Data.Migrations.Postgres +{ + [DbContext(typeof(PostgresWerkrDbContext))] + partial class PostgresWerkrDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("werkr") + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Werkr.Data.Entities.Registration.RegisteredConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActiveKeyId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("active_key_id"); + + b.Property("AllowedPaths") + .IsRequired() + .HasColumnType("text") + .HasColumnName("allowed_paths"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("connection_name"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("EnforceAllowlist") + .HasColumnType("boolean") + .HasColumnName("enforce_allowlist"); + + b.Property("InboundApiKeyHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("inbound_api_key_hash"); + + b.Property("IsServer") + .HasColumnType("boolean") + .HasColumnName("is_server"); + + b.Property("LastSeen") + .HasColumnType("text") + .HasColumnName("last_seen"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("LocalPrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("local_private_key"); + + b.Property("LocalPublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("local_public_key"); + + b.Property("OutboundApiKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("outbound_api_key"); + + b.Property("PreviousKeyId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("previous_key_id"); + + b.Property("PreviousSharedKey") + .HasColumnType("text") + .HasColumnName("previous_shared_key"); + + b.Property("RemotePublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("remote_public_key"); + + b.Property("RemoteUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("remote_url"); + + b.Property("SharedKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("shared_key"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tags"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_registered_connections"); + + b.HasIndex("ConnectionName") + .HasDatabaseName("ix_registered_connections_connection_name"); + + b.HasIndex("RemoteUrl") + .HasDatabaseName("ix_registered_connections_remote_url"); + + b.ToTable("registered_connections", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Registration.RegistrationBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowedPaths") + .IsRequired() + .HasColumnType("text") + .HasColumnName("allowed_paths"); + + b.Property("BundleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("bundle_id"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("connection_name"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("ExpiresAt") + .IsRequired() + .HasColumnType("text") + .HasColumnName("expires_at"); + + b.Property("KeySize") + .HasColumnType("integer") + .HasColumnName("key_size"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("ServerPrivateKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("server_private_key"); + + b.Property("ServerPublicKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("server_public_key"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_registration_bundles"); + + b.HasIndex("BundleId") + .IsUnique() + .HasDatabaseName("ix_registration_bundles_bundle_id"); + + b.ToTable("registration_bundles", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DailyRecurrence", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("DayInterval") + .HasColumnType("integer") + .HasColumnName("day_interval"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_daily_recurrence"); + + b.ToTable("daily_recurrence", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DbSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("StopTaskAfterMinutes") + .HasColumnType("bigint") + .HasColumnName("stop_task_after_minutes"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_schedules"); + + b.ToTable("schedules", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ExpirationDateTimeInfo", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Time") + .HasColumnType("time without time zone") + .HasColumnName("time"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("time_zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_schedule_expiration"); + + b.ToTable("schedule_expiration", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayCalendar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedUtc") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_utc"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("description"); + + b.Property("IsSystemCalendar") + .HasColumnType("boolean") + .HasColumnName("is_system_calendar"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("UpdatedUtc") + .IsRequired() + .HasColumnType("text") + .HasColumnName("updated_utc"); + + b.HasKey("Id") + .HasName("pk_holiday_calendars"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_holiday_calendars_name"); + + b.ToTable("holiday_calendars", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayDate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("HolidayCalendarId") + .HasColumnType("uuid") + .HasColumnName("holiday_calendar_id"); + + b.Property("HolidayRuleId") + .HasColumnType("bigint") + .HasColumnName("holiday_rule_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("WindowEnd") + .HasColumnType("time without time zone") + .HasColumnName("window_end"); + + b.Property("WindowStart") + .HasColumnType("time without time zone") + .HasColumnName("window_start"); + + b.Property("WindowTimeZoneId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("window_time_zone_id"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_holiday_dates"); + + b.HasIndex("HolidayRuleId") + .HasDatabaseName("ix_holiday_dates_holiday_rule_id"); + + b.HasIndex("HolidayCalendarId", "Date") + .IsUnique() + .HasDatabaseName("ix_holiday_dates_holiday_calendar_id_date"); + + b.ToTable("holiday_dates", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("Day") + .HasColumnType("integer") + .HasColumnName("day"); + + b.Property("DayOfWeek") + .HasColumnType("integer") + .HasColumnName("day_of_week"); + + b.Property("HolidayCalendarId") + .HasColumnType("uuid") + .HasColumnName("holiday_calendar_id"); + + b.Property("Month") + .HasColumnType("integer") + .HasColumnName("month"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ObservanceRule") + .IsRequired() + .HasColumnType("text") + .HasColumnName("observance_rule"); + + b.Property("RuleType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rule_type"); + + b.Property("WeekNumber") + .HasColumnType("integer") + .HasColumnName("week_number"); + + b.Property("WindowEnd") + .HasColumnType("time without time zone") + .HasColumnName("window_end"); + + b.Property("WindowStart") + .HasColumnType("time without time zone") + .HasColumnName("window_start"); + + b.Property("WindowTimeZoneId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("window_time_zone_id"); + + b.Property("YearEnd") + .HasColumnType("integer") + .HasColumnName("year_end"); + + b.Property("YearStart") + .HasColumnType("integer") + .HasColumnName("year_start"); + + b.HasKey("Id") + .HasName("pk_holiday_rules"); + + b.HasIndex("HolidayCalendarId") + .HasDatabaseName("ix_holiday_rules_holiday_calendar_id"); + + b.ToTable("holiday_rules", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.MonthlyRecurrence", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("DayNumbers") + .HasColumnType("text") + .HasColumnName("day_numbers"); + + b.Property("DaysOfWeek") + .HasColumnType("integer") + .HasColumnName("days_of_week"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("MonthsOfYear") + .HasColumnType("integer") + .HasColumnName("months_of_year"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WeekNumber") + .HasColumnType("integer") + .HasColumnName("week_number"); + + b.HasKey("ScheduleId") + .HasName("pk_monthly_recurrence"); + + b.ToTable("monthly_recurrence", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("CalendarName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("calendar_name"); + + b.Property("CreatedUtc") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_utc"); + + b.Property("HolidayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("holiday_name"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("mode"); + + b.Property("OccurrenceUtcTime") + .IsRequired() + .HasColumnType("text") + .HasColumnName("occurrence_utc_time"); + + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.HasKey("Id") + .HasName("pk_schedule_audit_log"); + + b.HasIndex("ScheduleId", "OccurrenceUtcTime") + .HasDatabaseName("ix_schedule_audit_log_schedule_id_occurrence_utc_time"); + + b.ToTable("schedule_audit_log", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleHolidayCalendar", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("HolidayCalendarId") + .HasColumnType("uuid") + .HasColumnName("holiday_calendar_id"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("mode"); + + b.HasKey("ScheduleId", "HolidayCalendarId") + .HasName("pk_schedule_holiday_calendars"); + + b.HasIndex("HolidayCalendarId") + .HasDatabaseName("ix_schedule_holiday_calendars_holiday_calendar_id"); + + b.HasIndex("ScheduleId") + .IsUnique() + .HasDatabaseName("ix_schedule_holiday_calendars_schedule_id"); + + b.ToTable("schedule_holiday_calendars", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleRepeatOptions", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("RepeatDurationMinutes") + .HasColumnType("integer") + .HasColumnName("repeat_duration_minutes"); + + b.Property("RepeatIntervalMinutes") + .HasColumnType("integer") + .HasColumnName("repeat_interval_minutes"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_schedule_repeat_options"); + + b.ToTable("schedule_repeat_options", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.StartDateTimeInfo", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Time") + .HasColumnType("time without time zone") + .HasColumnName("time"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("time_zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_schedule_start_datetimeinfo"); + + b.ToTable("schedule_start_datetimeinfo", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.WeeklyRecurrence", b => + { + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("DaysOfWeek") + .HasColumnType("integer") + .HasColumnName("days_of_week"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WeekInterval") + .HasColumnType("integer") + .HasColumnName("week_interval"); + + b.HasKey("ScheduleId") + .HasName("pk_weekly_recurrence"); + + b.ToTable("weekly_recurrence", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AgentConnectionId") + .HasColumnType("uuid") + .HasColumnName("agent_connection_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("EndTime") + .HasColumnType("text") + .HasColumnName("end_time"); + + b.Property("ErrorCategory") + .IsRequired() + .HasColumnType("text") + .HasColumnName("error_category"); + + b.Property("ExitCode") + .HasColumnType("integer") + .HasColumnName("exit_code"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Output") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("output"); + + b.Property("OutputPath") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("output_path"); + + b.Property("RuntimeSeconds") + .HasColumnType("double precision") + .HasColumnName("runtime_seconds"); + + b.Property("StartTime") + .IsRequired() + .HasColumnType("text") + .HasColumnName("start_time"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.Property("TaskId") + .HasColumnType("bigint") + .HasColumnName("task_id"); + + b.Property("TaskSnapshot") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("task_snapshot"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WorkflowRunId") + .HasColumnType("uuid") + .HasColumnName("workflow_run_id"); + + b.HasKey("Id") + .HasName("pk_jobs"); + + b.HasIndex("AgentConnectionId") + .HasDatabaseName("ix_jobs_agent_connection_id"); + + b.HasIndex("TaskId") + .HasDatabaseName("ix_jobs_task_id"); + + b.HasIndex("WorkflowRunId") + .HasDatabaseName("ix_jobs_workflow_run_id"); + + b.ToTable("jobs", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionParameters") + .HasColumnType("text") + .HasColumnName("action_parameters"); + + b.Property("ActionSubType") + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("action_sub_type"); + + b.Property("ActionType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("action_type"); + + b.Property("Arguments") + .HasColumnType("text") + .HasColumnName("arguments"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("character varying(8000)") + .HasColumnName("content"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("SuccessCriteria") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("success_criteria"); + + b.Property("SyncIntervalMinutes") + .HasColumnType("integer") + .HasColumnName("sync_interval_minutes"); + + b.Property("TargetTags") + .IsRequired() + .HasColumnType("text") + .HasColumnName("target_tags"); + + b.Property("TimeoutMinutes") + .HasColumnType("bigint") + .HasColumnName("timeout_minutes"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WorkflowId") + .HasColumnType("bigint") + .HasColumnName("workflow_id"); + + b.HasKey("Id") + .HasName("pk_tasks"); + + b.HasIndex("WorkflowId") + .HasDatabaseName("ix_tasks_workflow_id"); + + b.ToTable("tasks", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_workflows"); + + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_workflows_schedule_id"); + + b.ToTable("workflows", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("EndTime") + .HasColumnType("text") + .HasColumnName("end_time"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("StartTime") + .IsRequired() + .HasColumnType("text") + .HasColumnName("start_time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WorkflowId") + .HasColumnType("bigint") + .HasColumnName("workflow_id"); + + b.HasKey("Id") + .HasName("pk_workflow_runs"); + + b.HasIndex("WorkflowId") + .HasDatabaseName("ix_workflow_runs_workflow_id"); + + b.ToTable("workflow_runs", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentConnectionIdOverride") + .HasColumnType("uuid") + .HasColumnName("agent_connection_id_override"); + + b.Property("ConditionExpression") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("condition_expression"); + + b.Property("ControlStatement") + .IsRequired() + .HasColumnType("text") + .HasColumnName("control_statement"); + + b.Property("Created") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created"); + + b.Property("DependencyMode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("dependency_mode"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_updated"); + + b.Property("MaxIterations") + .HasColumnType("integer") + .HasColumnName("max_iterations"); + + b.Property("Order") + .HasColumnType("integer") + .HasColumnName("order"); + + b.Property("TaskId") + .HasColumnType("bigint") + .HasColumnName("task_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("WorkflowId") + .HasColumnType("bigint") + .HasColumnName("workflow_id"); + + b.HasKey("Id") + .HasName("pk_workflow_steps"); + + b.HasIndex("AgentConnectionIdOverride") + .HasDatabaseName("ix_workflow_steps_agent_connection_id_override"); + + b.HasIndex("TaskId") + .HasDatabaseName("ix_workflow_steps_task_id"); + + b.HasIndex("WorkflowId") + .HasDatabaseName("ix_workflow_steps_workflow_id"); + + b.ToTable("workflow_steps", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStepDependency", b => + { + b.Property("StepId") + .HasColumnType("bigint") + .HasColumnName("step_id"); + + b.Property("DependsOnStepId") + .HasColumnType("bigint") + .HasColumnName("depends_on_step_id"); + + b.HasKey("StepId", "DependsOnStepId") + .HasName("pk_workflow_step_dependencies"); + + b.HasIndex("DependsOnStepId") + .HasDatabaseName("ix_workflow_step_dependencies_depends_on_step_id"); + + b.ToTable("workflow_step_dependencies", "werkr"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DailyRecurrence", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("DailyRecurrence") + .HasForeignKey("Werkr.Data.Entities.Schedule.DailyRecurrence", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_daily_recurrence_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ExpirationDateTimeInfo", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("Expiration") + .HasForeignKey("Werkr.Data.Entities.Schedule.ExpirationDateTimeInfo", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_expiration_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayDate", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.HolidayCalendar", "Calendar") + .WithMany("Dates") + .HasForeignKey("HolidayCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_holiday_dates_holiday_calendars_holiday_calendar_id"); + + b.HasOne("Werkr.Data.Entities.Schedule.HolidayRule", "GeneratedByRule") + .WithMany("GeneratedDates") + .HasForeignKey("HolidayRuleId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_holiday_dates_holiday_rules_holiday_rule_id"); + + b.Navigation("Calendar"); + + b.Navigation("GeneratedByRule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayRule", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.HolidayCalendar", "Calendar") + .WithMany("Rules") + .HasForeignKey("HolidayCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_holiday_rules_holiday_calendars_holiday_calendar_id"); + + b.Navigation("Calendar"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.MonthlyRecurrence", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("MonthlyRecurrence") + .HasForeignKey("Werkr.Data.Entities.Schedule.MonthlyRecurrence", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_monthly_recurrence_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleAuditLog", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_audit_log_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleHolidayCalendar", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.HolidayCalendar", "Calendar") + .WithMany("ScheduleLinks") + .HasForeignKey("HolidayCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_holiday_calendars_holiday_calendars_holiday_calend"); + + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("HolidayCalendarLink") + .HasForeignKey("Werkr.Data.Entities.Schedule.ScheduleHolidayCalendar", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_holiday_calendars_schedules_schedule_id"); + + b.Navigation("Calendar"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleRepeatOptions", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("RepeatOptions") + .HasForeignKey("Werkr.Data.Entities.Schedule.ScheduleRepeatOptions", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_repeat_options_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.StartDateTimeInfo", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("StartDateTime") + .HasForeignKey("Werkr.Data.Entities.Schedule.StartDateTimeInfo", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_start_datetimeinfo_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.WeeklyRecurrence", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("WeeklyRecurrence") + .HasForeignKey("Werkr.Data.Entities.Schedule.WeeklyRecurrence", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_weekly_recurrence_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => + { + b.HasOne("Werkr.Data.Entities.Registration.RegisteredConnection", "AgentConnection") + .WithMany() + .HasForeignKey("AgentConnectionId") + .HasConstraintName("fk_jobs_registered_connections_agent_connection_id"); + + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_jobs_tasks_task_id"); + + b.HasOne("Werkr.Data.Entities.Workflows.WorkflowRun", "WorkflowRun") + .WithMany("Jobs") + .HasForeignKey("WorkflowRunId") + .HasConstraintName("fk_jobs_workflow_runs_workflow_run_id"); + + b.Navigation("AgentConnection"); + + b.Navigation("Task"); + + b.Navigation("WorkflowRun"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrTask", b => + { + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Tasks") + .HasForeignKey("WorkflowId") + .HasConstraintName("fk_tasks_workflows_workflow_id"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .HasConstraintName("fk_workflows_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + { + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Runs") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_runs_workflows_workflow_id"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => + { + b.HasOne("Werkr.Data.Entities.Registration.RegisteredConnection", "AgentConnectionOverride") + .WithMany() + .HasForeignKey("AgentConnectionIdOverride") + .HasConstraintName("fk_workflow_steps_registered_connections_agent_connection_id_o"); + + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_steps_tasks_task_id"); + + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Steps") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_steps_workflows_workflow_id"); + + b.Navigation("AgentConnectionOverride"); + + b.Navigation("Task"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStepDependency", b => + { + b.HasOne("Werkr.Data.Entities.Workflows.WorkflowStep", "DependsOnStep") + .WithMany("Dependents") + .HasForeignKey("DependsOnStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_workflow_step_dependencies_workflow_steps_depends_on_step_id"); + + b.HasOne("Werkr.Data.Entities.Workflows.WorkflowStep", "Step") + .WithMany("Dependencies") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_step_dependencies_workflow_steps_step_id"); + + b.Navigation("DependsOnStep"); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DbSchedule", b => + { + b.Navigation("DailyRecurrence"); + + b.Navigation("Expiration"); + + b.Navigation("HolidayCalendarLink"); + + b.Navigation("MonthlyRecurrence"); + + b.Navigation("RepeatOptions"); + + b.Navigation("StartDateTime"); + + b.Navigation("WeeklyRecurrence"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayCalendar", b => + { + b.Navigation("Dates"); + + b.Navigation("Rules"); + + b.Navigation("ScheduleLinks"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayRule", b => + { + b.Navigation("GeneratedDates"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + { + b.Navigation("Runs"); + + b.Navigation("Steps"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + { + b.Navigation("Jobs"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => + { + b.Navigation("Dependencies"); + + b.Navigation("Dependents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.Designer.cs b/src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.Designer.cs new file mode 100644 index 0000000..cd20a73 --- /dev/null +++ b/src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.Designer.cs @@ -0,0 +1,1408 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Werkr.Data; + +#nullable disable + +namespace Werkr.Data.Migrations.Sqlite +{ + [DbContext(typeof(SqliteWerkrDbContext))] + [Migration("20260308033950_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Werkr.Data.Entities.Registration.RegisteredConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActiveKeyId") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("active_key_id"); + + b.Property("AllowedPaths") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("allowed_paths"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("connection_name"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("EnforceAllowlist") + .HasColumnType("INTEGER") + .HasColumnName("enforce_allowlist"); + + b.Property("InboundApiKeyHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT") + .HasColumnName("inbound_api_key_hash"); + + b.Property("IsServer") + .HasColumnType("INTEGER") + .HasColumnName("is_server"); + + b.Property("LastSeen") + .HasColumnType("TEXT") + .HasColumnName("last_seen"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("LocalPrivateKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("local_private_key"); + + b.Property("LocalPublicKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("local_public_key"); + + b.Property("OutboundApiKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT") + .HasColumnName("outbound_api_key"); + + b.Property("PreviousKeyId") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("previous_key_id"); + + b.Property("PreviousSharedKey") + .HasColumnType("TEXT") + .HasColumnName("previous_shared_key"); + + b.Property("RemotePublicKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("remote_public_key"); + + b.Property("RemoteUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT") + .HasColumnName("remote_url"); + + b.Property("SharedKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("shared_key"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_registered_connections"); + + b.HasIndex("ConnectionName") + .HasDatabaseName("ix_registered_connections_connection_name"); + + b.HasIndex("RemoteUrl") + .HasDatabaseName("ix_registered_connections_remote_url"); + + b.ToTable("registered_connections", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Registration.RegistrationBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AllowedPaths") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("allowed_paths"); + + b.Property("BundleId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("bundle_id"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("connection_name"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("ExpiresAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("expires_at"); + + b.Property("KeySize") + .HasColumnType("INTEGER") + .HasColumnName("key_size"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("ServerPrivateKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("server_private_key"); + + b.Property("ServerPublicKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("server_public_key"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_registration_bundles"); + + b.HasIndex("BundleId") + .IsUnique() + .HasDatabaseName("ix_registration_bundles_bundle_id"); + + b.ToTable("registration_bundles", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DailyRecurrence", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("DayInterval") + .HasColumnType("INTEGER") + .HasColumnName("day_interval"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_daily_recurrence"); + + b.ToTable("daily_recurrence", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DbSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("StopTaskAfterMinutes") + .HasColumnType("INTEGER") + .HasColumnName("stop_task_after_minutes"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_schedules"); + + b.ToTable("schedules", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ExpirationDateTimeInfo", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Time") + .HasColumnType("TEXT") + .HasColumnName("time"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("time_zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_schedule_expiration"); + + b.ToTable("schedule_expiration", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayCalendar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedUtc") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_utc"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("IsSystemCalendar") + .HasColumnType("INTEGER") + .HasColumnName("is_system_calendar"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("UpdatedUtc") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("updated_utc"); + + b.HasKey("Id") + .HasName("pk_holiday_calendars"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_holiday_calendars_name"); + + b.ToTable("holiday_calendars", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayDate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("HolidayCalendarId") + .HasColumnType("TEXT") + .HasColumnName("holiday_calendar_id"); + + b.Property("HolidayRuleId") + .HasColumnType("INTEGER") + .HasColumnName("holiday_rule_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("WindowEnd") + .HasColumnType("TEXT") + .HasColumnName("window_end"); + + b.Property("WindowStart") + .HasColumnType("TEXT") + .HasColumnName("window_start"); + + b.Property("WindowTimeZoneId") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("window_time_zone_id"); + + b.Property("Year") + .HasColumnType("INTEGER") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_holiday_dates"); + + b.HasIndex("HolidayRuleId") + .HasDatabaseName("ix_holiday_dates_holiday_rule_id"); + + b.HasIndex("HolidayCalendarId", "Date") + .IsUnique() + .HasDatabaseName("ix_holiday_dates_holiday_calendar_id_date"); + + b.ToTable("holiday_dates", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn); + + b.Property("Day") + .HasColumnType("INTEGER") + .HasColumnName("day"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER") + .HasColumnName("day_of_week"); + + b.Property("HolidayCalendarId") + .HasColumnType("TEXT") + .HasColumnName("holiday_calendar_id"); + + b.Property("Month") + .HasColumnType("INTEGER") + .HasColumnName("month"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("ObservanceRule") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("observance_rule"); + + b.Property("RuleType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("rule_type"); + + b.Property("WeekNumber") + .HasColumnType("INTEGER") + .HasColumnName("week_number"); + + b.Property("WindowEnd") + .HasColumnType("TEXT") + .HasColumnName("window_end"); + + b.Property("WindowStart") + .HasColumnType("TEXT") + .HasColumnName("window_start"); + + b.Property("WindowTimeZoneId") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("window_time_zone_id"); + + b.Property("YearEnd") + .HasColumnType("INTEGER") + .HasColumnName("year_end"); + + b.Property("YearStart") + .HasColumnType("INTEGER") + .HasColumnName("year_start"); + + b.HasKey("Id") + .HasName("pk_holiday_rules"); + + b.HasIndex("HolidayCalendarId") + .HasDatabaseName("ix_holiday_rules_holiday_calendar_id"); + + b.ToTable("holiday_rules", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.MonthlyRecurrence", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("DayNumbers") + .HasColumnType("TEXT") + .HasColumnName("day_numbers"); + + b.Property("DaysOfWeek") + .HasColumnType("INTEGER") + .HasColumnName("days_of_week"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("MonthsOfYear") + .HasColumnType("INTEGER") + .HasColumnName("months_of_year"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WeekNumber") + .HasColumnType("INTEGER") + .HasColumnName("week_number"); + + b.HasKey("ScheduleId") + .HasName("pk_monthly_recurrence"); + + b.ToTable("monthly_recurrence", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn); + + b.Property("CalendarName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("calendar_name"); + + b.Property("CreatedUtc") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_utc"); + + b.Property("HolidayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("holiday_name"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("OccurrenceUtcTime") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("occurrence_utc_time"); + + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.HasKey("Id") + .HasName("pk_schedule_audit_log"); + + b.HasIndex("ScheduleId", "OccurrenceUtcTime") + .HasDatabaseName("ix_schedule_audit_log_schedule_id_occurrence_utc_time"); + + b.ToTable("schedule_audit_log", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleHolidayCalendar", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("HolidayCalendarId") + .HasColumnType("TEXT") + .HasColumnName("holiday_calendar_id"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.HasKey("ScheduleId", "HolidayCalendarId") + .HasName("pk_schedule_holiday_calendars"); + + b.HasIndex("HolidayCalendarId") + .HasDatabaseName("ix_schedule_holiday_calendars_holiday_calendar_id"); + + b.HasIndex("ScheduleId") + .IsUnique() + .HasDatabaseName("ix_schedule_holiday_calendars_schedule_id"); + + b.ToTable("schedule_holiday_calendars", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleRepeatOptions", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("RepeatDurationMinutes") + .HasColumnType("INTEGER") + .HasColumnName("repeat_duration_minutes"); + + b.Property("RepeatIntervalMinutes") + .HasColumnType("INTEGER") + .HasColumnName("repeat_interval_minutes"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_schedule_repeat_options"); + + b.ToTable("schedule_repeat_options", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.StartDateTimeInfo", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Time") + .HasColumnType("TEXT") + .HasColumnName("time"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("time_zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_schedule_start_datetimeinfo"); + + b.ToTable("schedule_start_datetimeinfo", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.WeeklyRecurrence", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("DaysOfWeek") + .HasColumnType("INTEGER") + .HasColumnName("days_of_week"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WeekInterval") + .HasColumnType("INTEGER") + .HasColumnName("week_interval"); + + b.HasKey("ScheduleId") + .HasName("pk_weekly_recurrence"); + + b.ToTable("weekly_recurrence", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentConnectionId") + .HasColumnType("TEXT") + .HasColumnName("agent_connection_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("EndTime") + .HasColumnType("TEXT") + .HasColumnName("end_time"); + + b.Property("ErrorCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("error_category"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Output") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("output"); + + b.Property("OutputPath") + .HasMaxLength(512) + .HasColumnType("TEXT") + .HasColumnName("output_path"); + + b.Property("RuntimeSeconds") + .HasColumnType("REAL") + .HasColumnName("runtime_seconds"); + + b.Property("StartTime") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("start_time"); + + b.Property("Success") + .HasColumnType("INTEGER") + .HasColumnName("success"); + + b.Property("TaskId") + .HasColumnType("INTEGER") + .HasColumnName("task_id"); + + b.Property("TaskSnapshot") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("TEXT") + .HasColumnName("task_snapshot"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WorkflowRunId") + .HasColumnType("TEXT") + .HasColumnName("workflow_run_id"); + + b.HasKey("Id") + .HasName("pk_jobs"); + + b.HasIndex("AgentConnectionId") + .HasDatabaseName("ix_jobs_agent_connection_id"); + + b.HasIndex("TaskId") + .HasDatabaseName("ix_jobs_task_id"); + + b.HasIndex("WorkflowRunId") + .HasDatabaseName("ix_jobs_workflow_run_id"); + + b.ToTable("jobs", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("ActionParameters") + .HasColumnType("TEXT") + .HasColumnName("action_parameters"); + + b.Property("ActionSubType") + .HasMaxLength(30) + .HasColumnType("TEXT") + .HasColumnName("action_sub_type"); + + b.Property("ActionType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("action_type"); + + b.Property("Arguments") + .HasColumnType("TEXT") + .HasColumnName("arguments"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("TEXT") + .HasColumnName("content"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("SuccessCriteria") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("success_criteria"); + + b.Property("SyncIntervalMinutes") + .HasColumnType("INTEGER") + .HasColumnName("sync_interval_minutes"); + + b.Property("TargetTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_tags"); + + b.Property("TimeoutMinutes") + .HasColumnType("INTEGER") + .HasColumnName("timeout_minutes"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WorkflowId") + .HasColumnType("INTEGER") + .HasColumnName("workflow_id"); + + b.HasKey("Id") + .HasName("pk_tasks"); + + b.HasIndex("WorkflowId") + .HasDatabaseName("ix_tasks_workflow_id"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_workflows"); + + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_workflows_schedule_id"); + + b.ToTable("workflows", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("EndTime") + .HasColumnType("TEXT") + .HasColumnName("end_time"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("StartTime") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("start_time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WorkflowId") + .HasColumnType("INTEGER") + .HasColumnName("workflow_id"); + + b.HasKey("Id") + .HasName("pk_workflow_runs"); + + b.HasIndex("WorkflowId") + .HasDatabaseName("ix_workflow_runs_workflow_id"); + + b.ToTable("workflow_runs", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("AgentConnectionIdOverride") + .HasColumnType("TEXT") + .HasColumnName("agent_connection_id_override"); + + b.Property("ConditionExpression") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("condition_expression"); + + b.Property("ControlStatement") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("control_statement"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("DependencyMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("dependency_mode"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("MaxIterations") + .HasColumnType("INTEGER") + .HasColumnName("max_iterations"); + + b.Property("Order") + .HasColumnType("INTEGER") + .HasColumnName("order"); + + b.Property("TaskId") + .HasColumnType("INTEGER") + .HasColumnName("task_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WorkflowId") + .HasColumnType("INTEGER") + .HasColumnName("workflow_id"); + + b.HasKey("Id") + .HasName("pk_workflow_steps"); + + b.HasIndex("AgentConnectionIdOverride") + .HasDatabaseName("ix_workflow_steps_agent_connection_id_override"); + + b.HasIndex("TaskId") + .HasDatabaseName("ix_workflow_steps_task_id"); + + b.HasIndex("WorkflowId") + .HasDatabaseName("ix_workflow_steps_workflow_id"); + + b.ToTable("workflow_steps", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStepDependency", b => + { + b.Property("StepId") + .HasColumnType("INTEGER") + .HasColumnName("step_id"); + + b.Property("DependsOnStepId") + .HasColumnType("INTEGER") + .HasColumnName("depends_on_step_id"); + + b.HasKey("StepId", "DependsOnStepId") + .HasName("pk_workflow_step_dependencies"); + + b.HasIndex("DependsOnStepId") + .HasDatabaseName("ix_workflow_step_dependencies_depends_on_step_id"); + + b.ToTable("workflow_step_dependencies", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DailyRecurrence", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("DailyRecurrence") + .HasForeignKey("Werkr.Data.Entities.Schedule.DailyRecurrence", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_daily_recurrence_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ExpirationDateTimeInfo", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("Expiration") + .HasForeignKey("Werkr.Data.Entities.Schedule.ExpirationDateTimeInfo", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_expiration_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayDate", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.HolidayCalendar", "Calendar") + .WithMany("Dates") + .HasForeignKey("HolidayCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_holiday_dates_holiday_calendars_holiday_calendar_id"); + + b.HasOne("Werkr.Data.Entities.Schedule.HolidayRule", "GeneratedByRule") + .WithMany("GeneratedDates") + .HasForeignKey("HolidayRuleId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_holiday_dates_holiday_rules_holiday_rule_id"); + + b.Navigation("Calendar"); + + b.Navigation("GeneratedByRule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayRule", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.HolidayCalendar", "Calendar") + .WithMany("Rules") + .HasForeignKey("HolidayCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_holiday_rules_holiday_calendars_holiday_calendar_id"); + + b.Navigation("Calendar"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.MonthlyRecurrence", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("MonthlyRecurrence") + .HasForeignKey("Werkr.Data.Entities.Schedule.MonthlyRecurrence", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_monthly_recurrence_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleAuditLog", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_audit_log_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleHolidayCalendar", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.HolidayCalendar", "Calendar") + .WithMany("ScheduleLinks") + .HasForeignKey("HolidayCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_holiday_calendars_holiday_calendars_holiday_calendar_id"); + + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("HolidayCalendarLink") + .HasForeignKey("Werkr.Data.Entities.Schedule.ScheduleHolidayCalendar", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_holiday_calendars_schedules_schedule_id"); + + b.Navigation("Calendar"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleRepeatOptions", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("RepeatOptions") + .HasForeignKey("Werkr.Data.Entities.Schedule.ScheduleRepeatOptions", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_repeat_options_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.StartDateTimeInfo", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("StartDateTime") + .HasForeignKey("Werkr.Data.Entities.Schedule.StartDateTimeInfo", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_start_datetimeinfo_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.WeeklyRecurrence", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("WeeklyRecurrence") + .HasForeignKey("Werkr.Data.Entities.Schedule.WeeklyRecurrence", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_weekly_recurrence_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => + { + b.HasOne("Werkr.Data.Entities.Registration.RegisteredConnection", "AgentConnection") + .WithMany() + .HasForeignKey("AgentConnectionId") + .HasConstraintName("fk_jobs_registered_connections_agent_connection_id"); + + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_jobs_tasks_task_id"); + + b.HasOne("Werkr.Data.Entities.Workflows.WorkflowRun", "WorkflowRun") + .WithMany("Jobs") + .HasForeignKey("WorkflowRunId") + .HasConstraintName("fk_jobs_workflow_runs_workflow_run_id"); + + b.Navigation("AgentConnection"); + + b.Navigation("Task"); + + b.Navigation("WorkflowRun"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrTask", b => + { + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Tasks") + .HasForeignKey("WorkflowId") + .HasConstraintName("fk_tasks_workflows_workflow_id"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .HasConstraintName("fk_workflows_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + { + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Runs") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_runs_workflows_workflow_id"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => + { + b.HasOne("Werkr.Data.Entities.Registration.RegisteredConnection", "AgentConnectionOverride") + .WithMany() + .HasForeignKey("AgentConnectionIdOverride") + .HasConstraintName("fk_workflow_steps_registered_connections_agent_connection_id_override"); + + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_steps_tasks_task_id"); + + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Steps") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_steps_workflows_workflow_id"); + + b.Navigation("AgentConnectionOverride"); + + b.Navigation("Task"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStepDependency", b => + { + b.HasOne("Werkr.Data.Entities.Workflows.WorkflowStep", "DependsOnStep") + .WithMany("Dependents") + .HasForeignKey("DependsOnStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_workflow_step_dependencies_workflow_steps_depends_on_step_id"); + + b.HasOne("Werkr.Data.Entities.Workflows.WorkflowStep", "Step") + .WithMany("Dependencies") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_step_dependencies_workflow_steps_step_id"); + + b.Navigation("DependsOnStep"); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DbSchedule", b => + { + b.Navigation("DailyRecurrence"); + + b.Navigation("Expiration"); + + b.Navigation("HolidayCalendarLink"); + + b.Navigation("MonthlyRecurrence"); + + b.Navigation("RepeatOptions"); + + b.Navigation("StartDateTime"); + + b.Navigation("WeeklyRecurrence"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayCalendar", b => + { + b.Navigation("Dates"); + + b.Navigation("Rules"); + + b.Navigation("ScheduleLinks"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayRule", b => + { + b.Navigation("GeneratedDates"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + { + b.Navigation("Runs"); + + b.Navigation("Steps"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + { + b.Navigation("Jobs"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => + { + b.Navigation("Dependencies"); + + b.Navigation("Dependents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.cs b/src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.cs new file mode 100644 index 0000000..58ac474 --- /dev/null +++ b/src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.cs @@ -0,0 +1,658 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Werkr.Data.Migrations.Sqlite; +/// +public partial class InitialCreate : Migration { + /// + protected override void Up( MigrationBuilder migrationBuilder ) { + _ = migrationBuilder.CreateTable( + name: "holiday_calendars", + columns: table => new { + id = table.Column( type: "TEXT", nullable: false ), + name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), + description = table.Column( type: "TEXT", maxLength: 1024, nullable: false ), + is_system_calendar = table.Column( type: "INTEGER", nullable: false ), + created_utc = table.Column( type: "TEXT", nullable: false ), + updated_utc = table.Column( type: "TEXT", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_holiday_calendars", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "registered_connections", + columns: table => new { + id = table.Column( type: "TEXT", nullable: false ), + connection_name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), + remote_url = table.Column( type: "TEXT", maxLength: 2048, nullable: false ), + local_public_key = table.Column( type: "TEXT", nullable: false ), + local_private_key = table.Column( type: "TEXT", nullable: false ), + remote_public_key = table.Column( type: "TEXT", nullable: false ), + outbound_api_key = table.Column( type: "TEXT", maxLength: 512, nullable: false ), + inbound_api_key_hash = table.Column( type: "TEXT", maxLength: 512, nullable: false ), + shared_key = table.Column( type: "TEXT", nullable: false ), + previous_shared_key = table.Column( type: "TEXT", nullable: true ), + active_key_id = table.Column( type: "TEXT", maxLength: 128, nullable: true ), + previous_key_id = table.Column( type: "TEXT", maxLength: 128, nullable: true ), + is_server = table.Column( type: "INTEGER", nullable: false ), + status = table.Column( type: "TEXT", nullable: false ), + last_seen = table.Column( type: "TEXT", nullable: true ), + tags = table.Column( type: "TEXT", nullable: false ), + allowed_paths = table.Column( type: "TEXT", nullable: false ), + enforce_allowlist = table.Column( type: "INTEGER", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_registered_connections", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "registration_bundles", + columns: table => new { + id = table.Column( type: "TEXT", nullable: false ), + connection_name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), + server_public_key = table.Column( type: "TEXT", nullable: false ), + server_private_key = table.Column( type: "TEXT", nullable: false ), + bundle_id = table.Column( type: "TEXT", nullable: false ), + status = table.Column( type: "TEXT", nullable: false ), + expires_at = table.Column( type: "TEXT", nullable: false ), + key_size = table.Column( type: "INTEGER", nullable: false ), + tags = table.Column( type: "TEXT", nullable: false ), + allowed_paths = table.Column( type: "TEXT", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_registration_bundles", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "schedules", + columns: table => new { + id = table.Column( type: "TEXT", nullable: false ), + name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), + stop_task_after_minutes = table.Column( type: "INTEGER", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_schedules", x => x.id ); + } ); + + _ = migrationBuilder.CreateTable( + name: "holiday_rules", + columns: table => new { + id = table.Column( type: "INTEGER", nullable: false ) + .Annotation( "Sqlite:Autoincrement", true ), + holiday_calendar_id = table.Column( type: "TEXT", nullable: false ), + name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), + rule_type = table.Column( type: "TEXT", nullable: false ), + month = table.Column( type: "INTEGER", nullable: true ), + day = table.Column( type: "INTEGER", nullable: true ), + day_of_week = table.Column( type: "INTEGER", nullable: true ), + week_number = table.Column( type: "INTEGER", nullable: true ), + window_start = table.Column( type: "TEXT", nullable: true ), + window_end = table.Column( type: "TEXT", nullable: true ), + window_time_zone_id = table.Column( type: "TEXT", maxLength: 128, nullable: true ), + observance_rule = table.Column( type: "TEXT", nullable: false ), + year_start = table.Column( type: "INTEGER", nullable: true ), + year_end = table.Column( type: "INTEGER", nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_holiday_rules", x => x.id ); + _ = table.ForeignKey( + name: "fk_holiday_rules_holiday_calendars_holiday_calendar_id", + column: x => x.holiday_calendar_id, + principalTable: "holiday_calendars", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "daily_recurrence", + columns: table => new { + schedule_id = table.Column( type: "TEXT", nullable: false ), + day_interval = table.Column( type: "INTEGER", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_daily_recurrence", x => x.schedule_id ); + _ = table.ForeignKey( + name: "fk_daily_recurrence_schedules_schedule_id", + column: x => x.schedule_id, + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "monthly_recurrence", + columns: table => new { + schedule_id = table.Column( type: "TEXT", nullable: false ), + day_numbers = table.Column( type: "TEXT", nullable: true ), + months_of_year = table.Column( type: "INTEGER", nullable: false ), + week_number = table.Column( type: "INTEGER", nullable: true ), + days_of_week = table.Column( type: "INTEGER", nullable: true ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_monthly_recurrence", x => x.schedule_id ); + _ = table.ForeignKey( + name: "fk_monthly_recurrence_schedules_schedule_id", + column: x => x.schedule_id, + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "schedule_audit_log", + columns: table => new { + id = table.Column( type: "INTEGER", nullable: false ) + .Annotation( "Sqlite:Autoincrement", true ), + schedule_id = table.Column( type: "TEXT", nullable: false ), + occurrence_utc_time = table.Column( type: "TEXT", nullable: false ), + calendar_name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), + holiday_name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), + mode = table.Column( type: "TEXT", nullable: false ), + created_utc = table.Column( type: "TEXT", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_schedule_audit_log", x => x.id ); + _ = table.ForeignKey( + name: "fk_schedule_audit_log_schedules_schedule_id", + column: x => x.schedule_id, + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "schedule_expiration", + columns: table => new { + schedule_id = table.Column( type: "TEXT", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ), + date = table.Column( type: "TEXT", nullable: false ), + time = table.Column( type: "TEXT", nullable: false ), + time_zone = table.Column( type: "TEXT", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_schedule_expiration", x => x.schedule_id ); + _ = table.ForeignKey( + name: "fk_schedule_expiration_schedules_schedule_id", + column: x => x.schedule_id, + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "schedule_holiday_calendars", + columns: table => new { + schedule_id = table.Column( type: "TEXT", nullable: false ), + holiday_calendar_id = table.Column( type: "TEXT", nullable: false ), + mode = table.Column( type: "TEXT", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_schedule_holiday_calendars", x => new { x.schedule_id, x.holiday_calendar_id } ); + _ = table.ForeignKey( + name: "fk_schedule_holiday_calendars_holiday_calendars_holiday_calendar_id", + column: x => x.holiday_calendar_id, + principalTable: "holiday_calendars", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + _ = table.ForeignKey( + name: "fk_schedule_holiday_calendars_schedules_schedule_id", + column: x => x.schedule_id, + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "schedule_repeat_options", + columns: table => new { + schedule_id = table.Column( type: "TEXT", nullable: false ), + repeat_interval_minutes = table.Column( type: "INTEGER", nullable: false ), + repeat_duration_minutes = table.Column( type: "INTEGER", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_schedule_repeat_options", x => x.schedule_id ); + _ = table.ForeignKey( + name: "fk_schedule_repeat_options_schedules_schedule_id", + column: x => x.schedule_id, + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "schedule_start_datetimeinfo", + columns: table => new { + schedule_id = table.Column( type: "TEXT", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ), + date = table.Column( type: "TEXT", nullable: false ), + time = table.Column( type: "TEXT", nullable: false ), + time_zone = table.Column( type: "TEXT", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_schedule_start_datetimeinfo", x => x.schedule_id ); + _ = table.ForeignKey( + name: "fk_schedule_start_datetimeinfo_schedules_schedule_id", + column: x => x.schedule_id, + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "weekly_recurrence", + columns: table => new { + schedule_id = table.Column( type: "TEXT", nullable: false ), + week_interval = table.Column( type: "INTEGER", nullable: false ), + days_of_week = table.Column( type: "INTEGER", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_weekly_recurrence", x => x.schedule_id ); + _ = table.ForeignKey( + name: "fk_weekly_recurrence_schedules_schedule_id", + column: x => x.schedule_id, + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "workflows", + columns: table => new { + id = table.Column( type: "INTEGER", nullable: false ) + .Annotation( "Sqlite:Autoincrement", true ), + name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), + description = table.Column( type: "TEXT", maxLength: 2000, nullable: false ), + enabled = table.Column( type: "INTEGER", nullable: false ), + schedule_id = table.Column( type: "TEXT", nullable: true ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflows", x => x.id ); + _ = table.ForeignKey( + name: "fk_workflows_schedules_schedule_id", + column: x => x.schedule_id, + principalTable: "schedules", + principalColumn: "id" ); + } ); + + _ = migrationBuilder.CreateTable( + name: "holiday_dates", + columns: table => new { + id = table.Column( type: "INTEGER", nullable: false ) + .Annotation( "Sqlite:Autoincrement", true ), + holiday_calendar_id = table.Column( type: "TEXT", nullable: false ), + holiday_rule_id = table.Column( type: "INTEGER", nullable: true ), + date = table.Column( type: "TEXT", nullable: false ), + name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), + year = table.Column( type: "INTEGER", nullable: false ), + window_start = table.Column( type: "TEXT", nullable: true ), + window_end = table.Column( type: "TEXT", nullable: true ), + window_time_zone_id = table.Column( type: "TEXT", maxLength: 128, nullable: true ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_holiday_dates", x => x.id ); + _ = table.ForeignKey( + name: "fk_holiday_dates_holiday_calendars_holiday_calendar_id", + column: x => x.holiday_calendar_id, + principalTable: "holiday_calendars", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + _ = table.ForeignKey( + name: "fk_holiday_dates_holiday_rules_holiday_rule_id", + column: x => x.holiday_rule_id, + principalTable: "holiday_rules", + principalColumn: "id", + onDelete: ReferentialAction.SetNull ); + } ); + + _ = migrationBuilder.CreateTable( + name: "tasks", + columns: table => new { + id = table.Column( type: "INTEGER", nullable: false ) + .Annotation( "Sqlite:Autoincrement", true ), + name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), + description = table.Column( type: "TEXT", maxLength: 2000, nullable: false ), + action_type = table.Column( type: "TEXT", nullable: false ), + schedule_id = table.Column( type: "TEXT", nullable: true ), + workflow_id = table.Column( type: "INTEGER", nullable: true ), + content = table.Column( type: "TEXT", maxLength: 8000, nullable: false ), + arguments = table.Column( type: "TEXT", nullable: true ), + target_tags = table.Column( type: "TEXT", nullable: false ), + enabled = table.Column( type: "INTEGER", nullable: false ), + timeout_minutes = table.Column( type: "INTEGER", nullable: true ), + sync_interval_minutes = table.Column( type: "INTEGER", nullable: false ), + success_criteria = table.Column( type: "TEXT", maxLength: 500, nullable: true ), + action_sub_type = table.Column( type: "TEXT", maxLength: 30, nullable: true ), + action_parameters = table.Column( type: "TEXT", nullable: true ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_tasks", x => x.id ); + _ = table.ForeignKey( + name: "fk_tasks_workflows_workflow_id", + column: x => x.workflow_id, + principalTable: "workflows", + principalColumn: "id" ); + } ); + + _ = migrationBuilder.CreateTable( + name: "workflow_runs", + columns: table => new { + id = table.Column( type: "TEXT", nullable: false ), + workflow_id = table.Column( type: "INTEGER", nullable: false ), + start_time = table.Column( type: "TEXT", nullable: false ), + end_time = table.Column( type: "TEXT", nullable: true ), + status = table.Column( type: "TEXT", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflow_runs", x => x.id ); + _ = table.ForeignKey( + name: "fk_workflow_runs_workflows_workflow_id", + column: x => x.workflow_id, + principalTable: "workflows", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "workflow_steps", + columns: table => new { + id = table.Column( type: "INTEGER", nullable: false ) + .Annotation( "Sqlite:Autoincrement", true ), + workflow_id = table.Column( type: "INTEGER", nullable: false ), + task_id = table.Column( type: "INTEGER", nullable: false ), + order = table.Column( type: "INTEGER", nullable: false ), + control_statement = table.Column( type: "TEXT", nullable: false ), + condition_expression = table.Column( type: "TEXT", maxLength: 2000, nullable: true ), + max_iterations = table.Column( type: "INTEGER", nullable: false ), + agent_connection_id_override = table.Column( type: "TEXT", nullable: true ), + dependency_mode = table.Column( type: "TEXT", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflow_steps", x => x.id ); + _ = table.ForeignKey( + name: "fk_workflow_steps_registered_connections_agent_connection_id_override", + column: x => x.agent_connection_id_override, + principalTable: "registered_connections", + principalColumn: "id" ); + _ = table.ForeignKey( + name: "fk_workflow_steps_tasks_task_id", + column: x => x.task_id, + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + _ = table.ForeignKey( + name: "fk_workflow_steps_workflows_workflow_id", + column: x => x.workflow_id, + principalTable: "workflows", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "jobs", + columns: table => new { + id = table.Column( type: "TEXT", nullable: false ), + task_id = table.Column( type: "INTEGER", nullable: false ), + task_snapshot = table.Column( type: "TEXT", maxLength: 8000, nullable: false ), + runtime_seconds = table.Column( type: "REAL", nullable: false ), + start_time = table.Column( type: "TEXT", nullable: false ), + end_time = table.Column( type: "TEXT", nullable: true ), + success = table.Column( type: "INTEGER", nullable: false ), + agent_connection_id = table.Column( type: "TEXT", nullable: true ), + exit_code = table.Column( type: "INTEGER", nullable: true ), + error_category = table.Column( type: "TEXT", nullable: false ), + output = table.Column( type: "TEXT", maxLength: 2000, nullable: true ), + output_path = table.Column( type: "TEXT", maxLength: 512, nullable: true ), + workflow_run_id = table.Column( type: "TEXT", nullable: true ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_jobs", x => x.id ); + _ = table.ForeignKey( + name: "fk_jobs_registered_connections_agent_connection_id", + column: x => x.agent_connection_id, + principalTable: "registered_connections", + principalColumn: "id" ); + _ = table.ForeignKey( + name: "fk_jobs_tasks_task_id", + column: x => x.task_id, + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + _ = table.ForeignKey( + name: "fk_jobs_workflow_runs_workflow_run_id", + column: x => x.workflow_run_id, + principalTable: "workflow_runs", + principalColumn: "id" ); + } ); + + _ = migrationBuilder.CreateTable( + name: "workflow_step_dependencies", + columns: table => new { + step_id = table.Column( type: "INTEGER", nullable: false ), + depends_on_step_id = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflow_step_dependencies", x => new { x.step_id, x.depends_on_step_id } ); + _ = table.ForeignKey( + name: "fk_workflow_step_dependencies_workflow_steps_depends_on_step_id", + column: x => x.depends_on_step_id, + principalTable: "workflow_steps", + principalColumn: "id", + onDelete: ReferentialAction.Restrict ); + _ = table.ForeignKey( + name: "fk_workflow_step_dependencies_workflow_steps_step_id", + column: x => x.step_id, + principalTable: "workflow_steps", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateIndex( + name: "ix_holiday_calendars_name", + table: "holiday_calendars", + column: "name", + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "ix_holiday_dates_holiday_calendar_id_date", + table: "holiday_dates", + columns: new[] { "holiday_calendar_id", "date" }, + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "ix_holiday_dates_holiday_rule_id", + table: "holiday_dates", + column: "holiday_rule_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_holiday_rules_holiday_calendar_id", + table: "holiday_rules", + column: "holiday_calendar_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_jobs_agent_connection_id", + table: "jobs", + column: "agent_connection_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_jobs_task_id", + table: "jobs", + column: "task_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_jobs_workflow_run_id", + table: "jobs", + column: "workflow_run_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_registered_connections_connection_name", + table: "registered_connections", + column: "connection_name" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_registered_connections_remote_url", + table: "registered_connections", + column: "remote_url" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_registration_bundles_bundle_id", + table: "registration_bundles", + column: "bundle_id", + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "ix_schedule_audit_log_schedule_id_occurrence_utc_time", + table: "schedule_audit_log", + columns: new[] { "schedule_id", "occurrence_utc_time" } ); + + _ = migrationBuilder.CreateIndex( + name: "ix_schedule_holiday_calendars_holiday_calendar_id", + table: "schedule_holiday_calendars", + column: "holiday_calendar_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_schedule_holiday_calendars_schedule_id", + table: "schedule_holiday_calendars", + column: "schedule_id", + unique: true ); + + _ = migrationBuilder.CreateIndex( + name: "ix_tasks_workflow_id", + table: "tasks", + column: "workflow_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_workflow_runs_workflow_id", + table: "workflow_runs", + column: "workflow_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_workflow_step_dependencies_depends_on_step_id", + table: "workflow_step_dependencies", + column: "depends_on_step_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_workflow_steps_agent_connection_id_override", + table: "workflow_steps", + column: "agent_connection_id_override" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_workflow_steps_task_id", + table: "workflow_steps", + column: "task_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_workflow_steps_workflow_id", + table: "workflow_steps", + column: "workflow_id" ); + + _ = migrationBuilder.CreateIndex( + name: "ix_workflows_schedule_id", + table: "workflows", + column: "schedule_id" ); + } + + /// + protected override void Down( MigrationBuilder migrationBuilder ) { + _ = migrationBuilder.DropTable( + name: "daily_recurrence" ); + + _ = migrationBuilder.DropTable( + name: "holiday_dates" ); + + _ = migrationBuilder.DropTable( + name: "jobs" ); + + _ = migrationBuilder.DropTable( + name: "monthly_recurrence" ); + + _ = migrationBuilder.DropTable( + name: "registration_bundles" ); + + _ = migrationBuilder.DropTable( + name: "schedule_audit_log" ); + + _ = migrationBuilder.DropTable( + name: "schedule_expiration" ); + + _ = migrationBuilder.DropTable( + name: "schedule_holiday_calendars" ); + + _ = migrationBuilder.DropTable( + name: "schedule_repeat_options" ); + + _ = migrationBuilder.DropTable( + name: "schedule_start_datetimeinfo" ); + + _ = migrationBuilder.DropTable( + name: "weekly_recurrence" ); + + _ = migrationBuilder.DropTable( + name: "workflow_step_dependencies" ); + + _ = migrationBuilder.DropTable( + name: "holiday_rules" ); + + _ = migrationBuilder.DropTable( + name: "workflow_runs" ); + + _ = migrationBuilder.DropTable( + name: "workflow_steps" ); + + _ = migrationBuilder.DropTable( + name: "holiday_calendars" ); + + _ = migrationBuilder.DropTable( + name: "registered_connections" ); + + _ = migrationBuilder.DropTable( + name: "tasks" ); + + _ = migrationBuilder.DropTable( + name: "workflows" ); + + _ = migrationBuilder.DropTable( + name: "schedules" ); + } +} diff --git a/src/Werkr.Data/Migrations/Sqlite/SqliteWerkrDbContextModelSnapshot.cs b/src/Werkr.Data/Migrations/Sqlite/SqliteWerkrDbContextModelSnapshot.cs new file mode 100644 index 0000000..b3b7239 --- /dev/null +++ b/src/Werkr.Data/Migrations/Sqlite/SqliteWerkrDbContextModelSnapshot.cs @@ -0,0 +1,1405 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Werkr.Data; + +#nullable disable + +namespace Werkr.Data.Migrations.Sqlite +{ + [DbContext(typeof(SqliteWerkrDbContext))] + partial class SqliteWerkrDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("Werkr.Data.Entities.Registration.RegisteredConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActiveKeyId") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("active_key_id"); + + b.Property("AllowedPaths") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("allowed_paths"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("connection_name"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("EnforceAllowlist") + .HasColumnType("INTEGER") + .HasColumnName("enforce_allowlist"); + + b.Property("InboundApiKeyHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT") + .HasColumnName("inbound_api_key_hash"); + + b.Property("IsServer") + .HasColumnType("INTEGER") + .HasColumnName("is_server"); + + b.Property("LastSeen") + .HasColumnType("TEXT") + .HasColumnName("last_seen"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("LocalPrivateKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("local_private_key"); + + b.Property("LocalPublicKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("local_public_key"); + + b.Property("OutboundApiKey") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT") + .HasColumnName("outbound_api_key"); + + b.Property("PreviousKeyId") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("previous_key_id"); + + b.Property("PreviousSharedKey") + .HasColumnType("TEXT") + .HasColumnName("previous_shared_key"); + + b.Property("RemotePublicKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("remote_public_key"); + + b.Property("RemoteUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT") + .HasColumnName("remote_url"); + + b.Property("SharedKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("shared_key"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_registered_connections"); + + b.HasIndex("ConnectionName") + .HasDatabaseName("ix_registered_connections_connection_name"); + + b.HasIndex("RemoteUrl") + .HasDatabaseName("ix_registered_connections_remote_url"); + + b.ToTable("registered_connections", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Registration.RegistrationBundle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AllowedPaths") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("allowed_paths"); + + b.Property("BundleId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("bundle_id"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("connection_name"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("ExpiresAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("expires_at"); + + b.Property("KeySize") + .HasColumnType("INTEGER") + .HasColumnName("key_size"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("ServerPrivateKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("server_private_key"); + + b.Property("ServerPublicKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("server_public_key"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_registration_bundles"); + + b.HasIndex("BundleId") + .IsUnique() + .HasDatabaseName("ix_registration_bundles_bundle_id"); + + b.ToTable("registration_bundles", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DailyRecurrence", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("DayInterval") + .HasColumnType("INTEGER") + .HasColumnName("day_interval"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_daily_recurrence"); + + b.ToTable("daily_recurrence", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DbSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("StopTaskAfterMinutes") + .HasColumnType("INTEGER") + .HasColumnName("stop_task_after_minutes"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_schedules"); + + b.ToTable("schedules", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ExpirationDateTimeInfo", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Time") + .HasColumnType("TEXT") + .HasColumnName("time"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("time_zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_schedule_expiration"); + + b.ToTable("schedule_expiration", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayCalendar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedUtc") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_utc"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("IsSystemCalendar") + .HasColumnType("INTEGER") + .HasColumnName("is_system_calendar"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("UpdatedUtc") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("updated_utc"); + + b.HasKey("Id") + .HasName("pk_holiday_calendars"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_holiday_calendars_name"); + + b.ToTable("holiday_calendars", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayDate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("HolidayCalendarId") + .HasColumnType("TEXT") + .HasColumnName("holiday_calendar_id"); + + b.Property("HolidayRuleId") + .HasColumnType("INTEGER") + .HasColumnName("holiday_rule_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("WindowEnd") + .HasColumnType("TEXT") + .HasColumnName("window_end"); + + b.Property("WindowStart") + .HasColumnType("TEXT") + .HasColumnName("window_start"); + + b.Property("WindowTimeZoneId") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("window_time_zone_id"); + + b.Property("Year") + .HasColumnType("INTEGER") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_holiday_dates"); + + b.HasIndex("HolidayRuleId") + .HasDatabaseName("ix_holiday_dates_holiday_rule_id"); + + b.HasIndex("HolidayCalendarId", "Date") + .IsUnique() + .HasDatabaseName("ix_holiday_dates_holiday_calendar_id_date"); + + b.ToTable("holiday_dates", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn); + + b.Property("Day") + .HasColumnType("INTEGER") + .HasColumnName("day"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER") + .HasColumnName("day_of_week"); + + b.Property("HolidayCalendarId") + .HasColumnType("TEXT") + .HasColumnName("holiday_calendar_id"); + + b.Property("Month") + .HasColumnType("INTEGER") + .HasColumnName("month"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("ObservanceRule") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("observance_rule"); + + b.Property("RuleType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("rule_type"); + + b.Property("WeekNumber") + .HasColumnType("INTEGER") + .HasColumnName("week_number"); + + b.Property("WindowEnd") + .HasColumnType("TEXT") + .HasColumnName("window_end"); + + b.Property("WindowStart") + .HasColumnType("TEXT") + .HasColumnName("window_start"); + + b.Property("WindowTimeZoneId") + .HasMaxLength(128) + .HasColumnType("TEXT") + .HasColumnName("window_time_zone_id"); + + b.Property("YearEnd") + .HasColumnType("INTEGER") + .HasColumnName("year_end"); + + b.Property("YearStart") + .HasColumnType("INTEGER") + .HasColumnName("year_start"); + + b.HasKey("Id") + .HasName("pk_holiday_rules"); + + b.HasIndex("HolidayCalendarId") + .HasDatabaseName("ix_holiday_rules_holiday_calendar_id"); + + b.ToTable("holiday_rules", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.MonthlyRecurrence", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("DayNumbers") + .HasColumnType("TEXT") + .HasColumnName("day_numbers"); + + b.Property("DaysOfWeek") + .HasColumnType("INTEGER") + .HasColumnName("days_of_week"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("MonthsOfYear") + .HasColumnType("INTEGER") + .HasColumnName("months_of_year"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WeekNumber") + .HasColumnType("INTEGER") + .HasColumnName("week_number"); + + b.HasKey("ScheduleId") + .HasName("pk_monthly_recurrence"); + + b.ToTable("monthly_recurrence", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn); + + b.Property("CalendarName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("calendar_name"); + + b.Property("CreatedUtc") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_utc"); + + b.Property("HolidayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("holiday_name"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("OccurrenceUtcTime") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("occurrence_utc_time"); + + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.HasKey("Id") + .HasName("pk_schedule_audit_log"); + + b.HasIndex("ScheduleId", "OccurrenceUtcTime") + .HasDatabaseName("ix_schedule_audit_log_schedule_id_occurrence_utc_time"); + + b.ToTable("schedule_audit_log", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleHolidayCalendar", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("HolidayCalendarId") + .HasColumnType("TEXT") + .HasColumnName("holiday_calendar_id"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.HasKey("ScheduleId", "HolidayCalendarId") + .HasName("pk_schedule_holiday_calendars"); + + b.HasIndex("HolidayCalendarId") + .HasDatabaseName("ix_schedule_holiday_calendars_holiday_calendar_id"); + + b.HasIndex("ScheduleId") + .IsUnique() + .HasDatabaseName("ix_schedule_holiday_calendars_schedule_id"); + + b.ToTable("schedule_holiday_calendars", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleRepeatOptions", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("RepeatDurationMinutes") + .HasColumnType("INTEGER") + .HasColumnName("repeat_duration_minutes"); + + b.Property("RepeatIntervalMinutes") + .HasColumnType("INTEGER") + .HasColumnName("repeat_interval_minutes"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_schedule_repeat_options"); + + b.ToTable("schedule_repeat_options", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.StartDateTimeInfo", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("date"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Time") + .HasColumnType("TEXT") + .HasColumnName("time"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("time_zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("ScheduleId") + .HasName("pk_schedule_start_datetimeinfo"); + + b.ToTable("schedule_start_datetimeinfo", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.WeeklyRecurrence", b => + { + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("DaysOfWeek") + .HasColumnType("INTEGER") + .HasColumnName("days_of_week"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WeekInterval") + .HasColumnType("INTEGER") + .HasColumnName("week_interval"); + + b.HasKey("ScheduleId") + .HasName("pk_weekly_recurrence"); + + b.ToTable("weekly_recurrence", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentConnectionId") + .HasColumnType("TEXT") + .HasColumnName("agent_connection_id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("EndTime") + .HasColumnType("TEXT") + .HasColumnName("end_time"); + + b.Property("ErrorCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("error_category"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Output") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("output"); + + b.Property("OutputPath") + .HasMaxLength(512) + .HasColumnType("TEXT") + .HasColumnName("output_path"); + + b.Property("RuntimeSeconds") + .HasColumnType("REAL") + .HasColumnName("runtime_seconds"); + + b.Property("StartTime") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("start_time"); + + b.Property("Success") + .HasColumnType("INTEGER") + .HasColumnName("success"); + + b.Property("TaskId") + .HasColumnType("INTEGER") + .HasColumnName("task_id"); + + b.Property("TaskSnapshot") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("TEXT") + .HasColumnName("task_snapshot"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WorkflowRunId") + .HasColumnType("TEXT") + .HasColumnName("workflow_run_id"); + + b.HasKey("Id") + .HasName("pk_jobs"); + + b.HasIndex("AgentConnectionId") + .HasDatabaseName("ix_jobs_agent_connection_id"); + + b.HasIndex("TaskId") + .HasDatabaseName("ix_jobs_task_id"); + + b.HasIndex("WorkflowRunId") + .HasDatabaseName("ix_jobs_workflow_run_id"); + + b.ToTable("jobs", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("ActionParameters") + .HasColumnType("TEXT") + .HasColumnName("action_parameters"); + + b.Property("ActionSubType") + .HasMaxLength(30) + .HasColumnType("TEXT") + .HasColumnName("action_sub_type"); + + b.Property("ActionType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("action_type"); + + b.Property("Arguments") + .HasColumnType("TEXT") + .HasColumnName("arguments"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("TEXT") + .HasColumnName("content"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("SuccessCriteria") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("success_criteria"); + + b.Property("SyncIntervalMinutes") + .HasColumnType("INTEGER") + .HasColumnName("sync_interval_minutes"); + + b.Property("TargetTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_tags"); + + b.Property("TimeoutMinutes") + .HasColumnType("INTEGER") + .HasColumnName("timeout_minutes"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WorkflowId") + .HasColumnType("INTEGER") + .HasColumnName("workflow_id"); + + b.HasKey("Id") + .HasName("pk_tasks"); + + b.HasIndex("WorkflowId") + .HasDatabaseName("ix_tasks_workflow_id"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_workflows"); + + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_workflows_schedule_id"); + + b.ToTable("workflows", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("EndTime") + .HasColumnType("TEXT") + .HasColumnName("end_time"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("StartTime") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("start_time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WorkflowId") + .HasColumnType("INTEGER") + .HasColumnName("workflow_id"); + + b.HasKey("Id") + .HasName("pk_workflow_runs"); + + b.HasIndex("WorkflowId") + .HasDatabaseName("ix_workflow_runs_workflow_id"); + + b.ToTable("workflow_runs", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("AgentConnectionIdOverride") + .HasColumnType("TEXT") + .HasColumnName("agent_connection_id_override"); + + b.Property("ConditionExpression") + .HasMaxLength(2000) + .HasColumnType("TEXT") + .HasColumnName("condition_expression"); + + b.Property("ControlStatement") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("control_statement"); + + b.Property("Created") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created"); + + b.Property("DependencyMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("dependency_mode"); + + b.Property("LastUpdated") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_updated"); + + b.Property("MaxIterations") + .HasColumnType("INTEGER") + .HasColumnName("max_iterations"); + + b.Property("Order") + .HasColumnType("INTEGER") + .HasColumnName("order"); + + b.Property("TaskId") + .HasColumnType("INTEGER") + .HasColumnName("task_id"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WorkflowId") + .HasColumnType("INTEGER") + .HasColumnName("workflow_id"); + + b.HasKey("Id") + .HasName("pk_workflow_steps"); + + b.HasIndex("AgentConnectionIdOverride") + .HasDatabaseName("ix_workflow_steps_agent_connection_id_override"); + + b.HasIndex("TaskId") + .HasDatabaseName("ix_workflow_steps_task_id"); + + b.HasIndex("WorkflowId") + .HasDatabaseName("ix_workflow_steps_workflow_id"); + + b.ToTable("workflow_steps", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStepDependency", b => + { + b.Property("StepId") + .HasColumnType("INTEGER") + .HasColumnName("step_id"); + + b.Property("DependsOnStepId") + .HasColumnType("INTEGER") + .HasColumnName("depends_on_step_id"); + + b.HasKey("StepId", "DependsOnStepId") + .HasName("pk_workflow_step_dependencies"); + + b.HasIndex("DependsOnStepId") + .HasDatabaseName("ix_workflow_step_dependencies_depends_on_step_id"); + + b.ToTable("workflow_step_dependencies", (string)null); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DailyRecurrence", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("DailyRecurrence") + .HasForeignKey("Werkr.Data.Entities.Schedule.DailyRecurrence", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_daily_recurrence_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ExpirationDateTimeInfo", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("Expiration") + .HasForeignKey("Werkr.Data.Entities.Schedule.ExpirationDateTimeInfo", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_expiration_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayDate", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.HolidayCalendar", "Calendar") + .WithMany("Dates") + .HasForeignKey("HolidayCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_holiday_dates_holiday_calendars_holiday_calendar_id"); + + b.HasOne("Werkr.Data.Entities.Schedule.HolidayRule", "GeneratedByRule") + .WithMany("GeneratedDates") + .HasForeignKey("HolidayRuleId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_holiday_dates_holiday_rules_holiday_rule_id"); + + b.Navigation("Calendar"); + + b.Navigation("GeneratedByRule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayRule", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.HolidayCalendar", "Calendar") + .WithMany("Rules") + .HasForeignKey("HolidayCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_holiday_rules_holiday_calendars_holiday_calendar_id"); + + b.Navigation("Calendar"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.MonthlyRecurrence", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("MonthlyRecurrence") + .HasForeignKey("Werkr.Data.Entities.Schedule.MonthlyRecurrence", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_monthly_recurrence_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleAuditLog", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_audit_log_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleHolidayCalendar", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.HolidayCalendar", "Calendar") + .WithMany("ScheduleLinks") + .HasForeignKey("HolidayCalendarId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_holiday_calendars_holiday_calendars_holiday_calendar_id"); + + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("HolidayCalendarLink") + .HasForeignKey("Werkr.Data.Entities.Schedule.ScheduleHolidayCalendar", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_holiday_calendars_schedules_schedule_id"); + + b.Navigation("Calendar"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.ScheduleRepeatOptions", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("RepeatOptions") + .HasForeignKey("Werkr.Data.Entities.Schedule.ScheduleRepeatOptions", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_repeat_options_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.StartDateTimeInfo", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("StartDateTime") + .HasForeignKey("Werkr.Data.Entities.Schedule.StartDateTimeInfo", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schedule_start_datetimeinfo_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.WeeklyRecurrence", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithOne("WeeklyRecurrence") + .HasForeignKey("Werkr.Data.Entities.Schedule.WeeklyRecurrence", "ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_weekly_recurrence_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => + { + b.HasOne("Werkr.Data.Entities.Registration.RegisteredConnection", "AgentConnection") + .WithMany() + .HasForeignKey("AgentConnectionId") + .HasConstraintName("fk_jobs_registered_connections_agent_connection_id"); + + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_jobs_tasks_task_id"); + + b.HasOne("Werkr.Data.Entities.Workflows.WorkflowRun", "WorkflowRun") + .WithMany("Jobs") + .HasForeignKey("WorkflowRunId") + .HasConstraintName("fk_jobs_workflow_runs_workflow_run_id"); + + b.Navigation("AgentConnection"); + + b.Navigation("Task"); + + b.Navigation("WorkflowRun"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrTask", b => + { + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Tasks") + .HasForeignKey("WorkflowId") + .HasConstraintName("fk_tasks_workflows_workflow_id"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .HasConstraintName("fk_workflows_schedules_schedule_id"); + + b.Navigation("Schedule"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + { + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Runs") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_runs_workflows_workflow_id"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => + { + b.HasOne("Werkr.Data.Entities.Registration.RegisteredConnection", "AgentConnectionOverride") + .WithMany() + .HasForeignKey("AgentConnectionIdOverride") + .HasConstraintName("fk_workflow_steps_registered_connections_agent_connection_id_override"); + + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_steps_tasks_task_id"); + + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Steps") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_steps_workflows_workflow_id"); + + b.Navigation("AgentConnectionOverride"); + + b.Navigation("Task"); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStepDependency", b => + { + b.HasOne("Werkr.Data.Entities.Workflows.WorkflowStep", "DependsOnStep") + .WithMany("Dependents") + .HasForeignKey("DependsOnStepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_workflow_step_dependencies_workflow_steps_depends_on_step_id"); + + b.HasOne("Werkr.Data.Entities.Workflows.WorkflowStep", "Step") + .WithMany("Dependencies") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_step_dependencies_workflow_steps_step_id"); + + b.Navigation("DependsOnStep"); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.DbSchedule", b => + { + b.Navigation("DailyRecurrence"); + + b.Navigation("Expiration"); + + b.Navigation("HolidayCalendarLink"); + + b.Navigation("MonthlyRecurrence"); + + b.Navigation("RepeatOptions"); + + b.Navigation("StartDateTime"); + + b.Navigation("WeeklyRecurrence"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayCalendar", b => + { + b.Navigation("Dates"); + + b.Navigation("Rules"); + + b.Navigation("ScheduleLinks"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayRule", b => + { + b.Navigation("GeneratedDates"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + { + b.Navigation("Runs"); + + b.Navigation("Steps"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + { + b.Navigation("Jobs"); + }); + + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => + { + b.Navigation("Dependencies"); + + b.Navigation("Dependents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Werkr.Server/Components/Layout/MainLayout.razor b/src/Werkr.Server/Components/Layout/MainLayout.razor index a2421b7..fd789fb 100644 --- a/src/Werkr.Server/Components/Layout/MainLayout.razor +++ b/src/Werkr.Server/Components/Layout/MainLayout.razor @@ -10,7 +10,7 @@
-
+
@context.User.Identity?.Name @(context.User.IsInRole( "Admin" ) ? "Admin" diff --git a/src/Werkr.Server/Components/Pages/Account/Manage/Mfa.razor b/src/Werkr.Server/Components/Pages/Account/Manage/Mfa.razor index 8e0a1b4..45b4aac 100644 --- a/src/Werkr.Server/Components/Pages/Account/Manage/Mfa.razor +++ b/src/Werkr.Server/Components/Pages/Account/Manage/Mfa.razor @@ -83,7 +83,7 @@ @if (_recoveryCodes.Count > 0) {
Save these codes. They will not be shown again.
-
@string.Join(Environment.NewLine, _recoveryCodes)
+
@string.Join(Environment.NewLine, _recoveryCodes)
}
diff --git a/src/Werkr.Server/Components/Pages/Agents/Index.razor b/src/Werkr.Server/Components/Pages/Agents/Index.razor index 7ab60cb..8dc527f 100644 --- a/src/Werkr.Server/Components/Pages/Agents/Index.razor +++ b/src/Werkr.Server/Components/Pages/Agents/Index.razor @@ -55,7 +55,7 @@ - + Console @if ( agent.Status != "Revoked" ) { diff --git a/src/Werkr.Server/Werkr.Server.csproj b/src/Werkr.Server/Werkr.Server.csproj index 3c378ed..a720cc5 100644 --- a/src/Werkr.Server/Werkr.Server.csproj +++ b/src/Werkr.Server/Werkr.Server.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Werkr.Server/packages.lock.json b/src/Werkr.Server/packages.lock.json index 46ecba5..e4e6e26 100644 --- a/src/Werkr.Server/packages.lock.json +++ b/src/Werkr.Server/packages.lock.json @@ -10,9 +10,26 @@ }, "Microsoft.AspNetCore.App.Internal.Assets": { "type": "Direct", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "0gZrESKwnlmbE8Br8XIy3kk7Pj0++9T2Ly+A8BFYYgo5EgfqWEln26cho+l92KOaHUzclhvz314RiwE910s24g==" + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "mr3Zn+ht8lijYvlMIasftw9opU9hsLKDdnOgQMmYI3RjWPJLOF9l8+YHDseRkTs97wOrULmJgo/NDCmzL/EGDg==" + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "OPZ/u7fONQFmnyUIDB8SeJtKnyFkj1zJsZ0Ke2Cp17q8hYs6jGmYEFd6Ne4Hdcd6auUdFdV7di+uFo2w+L34NA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } }, "Microsoft.IdentityModel.JsonWebTokens": { "type": "Direct", @@ -89,324 +106,149 @@ "Grpc.Core.Api": "2.70.0" } }, - "Microsoft.AspNetCore.Cryptography.Internal": { + "Humanizer.Core": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "STkCfgCECt2cAekgBpXxFDefH5wd4ytYZKihIZSmQqY92BP8N9qN71qFyRpry8Sl/qT5A+bpwe8v7sjDtg5LEA==" + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" }, - "Microsoft.AspNetCore.Cryptography.KeyDerivation": { + "Microsoft.Build.Framework": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "c8GgMKpnNf8fUOKXaZXKV5XaLSlvAts8ICvcPr5CIfjHEWJtbq+URIfBGYesyhnOlWAiSgVsdCBZxMEJIHgfLw==", - "dependencies": { - "Microsoft.AspNetCore.Cryptography.Internal": "10.0.3" - } + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" }, - "Microsoft.AspNetCore.Metadata": { + "Microsoft.CodeAnalysis.Analyzers": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" - }, - "Microsoft.EntityFrameworkCore.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" - }, - "Microsoft.EntityFrameworkCore.Analyzers": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" }, - "Microsoft.EntityFrameworkCore.Relational": { + "Microsoft.CodeAnalysis.Common": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" } }, - "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "Microsoft.CodeAnalysis.CSharp": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.3", - "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "SQLitePCLRaw.core": "2.1.11" + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" } }, - "Microsoft.Extensions.AmbientMetadata.Application": { + "Microsoft.CodeAnalysis.CSharp.Workspaces": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" } }, - "Microsoft.Extensions.Caching.Abstractions": { + "Microsoft.CodeAnalysis.Workspaces.Common": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" } }, - "Microsoft.Extensions.Caching.Memory": { + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" } }, - "Microsoft.Extensions.Compliance.Abstractions": { + "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3" - } + "resolved": "10.0.3", + "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" }, - "Microsoft.Extensions.Configuration": { + "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", "resolved": "10.0.3", - "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } + "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" }, - "Microsoft.Extensions.Configuration.Binder": { + "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", "resolved": "10.0.3", - "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + "Microsoft.EntityFrameworkCore": "10.0.3" } }, - "Microsoft.Extensions.Configuration.FileExtensions": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": { "type": "Transitive", "resolved": "10.0.3", - "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Physical": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Data.Sqlite.Core": "10.0.3", + "Microsoft.EntityFrameworkCore.Relational": "10.0.3", + "Microsoft.Extensions.DependencyModel": "10.0.3", + "SQLitePCLRaw.core": "2.1.11" } }, - "Microsoft.Extensions.DependencyInjection": { + "Microsoft.Extensions.AmbientMetadata.Application": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" - } + "resolved": "10.3.0", + "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==" }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { + "Microsoft.Extensions.Compliance.Abstractions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + "resolved": "10.3.0", + "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==" }, "Microsoft.Extensions.DependencyInjection.AutoActivation": { "type": "Transitive", "resolved": "10.3.0", - "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==", - "dependencies": { - "Microsoft.Extensions.Hosting.Abstractions": "10.0.3" - } + "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", "resolved": "10.0.3", "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" }, - "Microsoft.Extensions.Diagnostics": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } - }, "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { "type": "Transitive", "resolved": "10.3.0", - "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" - } - }, - "Microsoft.Extensions.Features": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "djFt1Jt+2uREWWVQiiA4ilYBDtHHY7nK08c5K8xBD9+XFNw3KDVprylrMkH08bZGK3ZHRAkS7JDV9srfLrcm/g==" - }, - "Microsoft.Extensions.FileProviders.Abstractions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" - } + "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==" }, "Microsoft.Extensions.FileProviders.Embedded": { "type": "Transitive", "resolved": "10.0.3", - "contentHash": "kw/xPl7m4Gv6bqx2ojihTtWiN2K2AklyMIrvncuSi2MOdwu0oMKoyh0G3p2Brt7m43Q9ER0IaA2G4EGjfgDh/w==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" - } - }, - "Microsoft.Extensions.FileProviders.Physical": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.FileSystemGlobbing": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" - }, - "Microsoft.Extensions.Http": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "M5gWob3dtzlF14oto1lR1ZuSJrR0gGc+obv7zY9LGmX5y3Ndpve29MrrjqJW/m4CFud4TE/KFUuHjjtwxhCO8g==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } + "contentHash": "kw/xPl7m4Gv6bqx2ojihTtWiN2K2AklyMIrvncuSi2MOdwu0oMKoyh0G3p2Brt7m43Q9ER0IaA2G4EGjfgDh/w==" }, "Microsoft.Extensions.Http.Diagnostics": { "type": "Transitive", "resolved": "10.3.0", "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", "dependencies": { - "Microsoft.Extensions.Http": "10.0.3", "Microsoft.Extensions.Telemetry": "10.3.0" } }, - "Microsoft.Extensions.Identity.Core": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "GdhTmz+BiVEdsFCT7Vqjhlx8q7j7kGPLinJjudPLO48DxZjSIwh9KlOd/AYJoGR21NjkkHiWijcB3RG7rIfMqw==", - "dependencies": { - "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.3", - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } - }, - "Microsoft.Extensions.Identity.Stores": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "XWu+Xg0dc0VKJxW7iTuhpnSD2jqZ4Kcdr7f3vUf7LOmPkawBLGkUuUA3rl+QQCbXAGnomV/I9T2wTxe1BKkVEA==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.3", - "Microsoft.Extensions.Identity.Core": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" - } - }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" - } - }, - "Microsoft.Extensions.Logging.Configuration": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "PBlaoYeusaxNYyN4WFjzcXWlUDSvLUPxy/e6oP1SONOOYA/oBWT2uBmFGJMV9VTtXiXXxCB39LqlYWbsWE4UKA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" - } - }, - "Microsoft.Extensions.ObjectPool": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "dQKlVXzqflsv5X8iDlAN5YmTL1GcLCrOLKo1s9PNdfjqxeu0S/jmWTfiLGno+8+o1qFL3+VFAH5/ftmypN+sPw==" - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" - }, "Microsoft.Extensions.Resilience": { "type": "Transitive", "resolved": "10.3.0", "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", "dependencies": { - "Microsoft.Extensions.Diagnostics": "10.0.3", "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3", "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", "Polly.Extensions": "8.4.2", "Polly.RateLimiting": "8.4.2" @@ -415,16 +257,7 @@ "Microsoft.Extensions.ServiceDiscovery.Abstractions": { "type": "Transitive", "resolved": "10.3.0", - "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Features": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" - } + "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==" }, "Microsoft.Extensions.Telemetry": { "type": "Transitive", @@ -433,8 +266,6 @@ "dependencies": { "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", - "Microsoft.Extensions.Logging.Configuration": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3", "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" } }, @@ -443,10 +274,7 @@ "resolved": "10.3.0", "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", "dependencies": { - "Microsoft.Extensions.Compliance.Abstractions": "10.3.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.Extensions.Compliance.Abstractions": "10.3.0" } }, "Microsoft.IdentityModel.Abstractions": { @@ -462,26 +290,39 @@ "Microsoft.IdentityModel.Abstractions": "8.16.0" } }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, "Microsoft.Win32.SystemEvents": { "type": "Transitive", "resolved": "6.0.0", "contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A==" }, - "Npgsql": { + "Mono.TextTemplating": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + "System.CodeDom": "6.0.0" } }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==" + }, "OpenTelemetry": { "type": "Transitive", "resolved": "1.15.0", "contentHash": "7mS/oZFF8S6xyqGQfMU1btp0nXJQUPWV535Vp/XMLYwRAUv36xQN+U4vufWBF1+z4HnRTOwuFHtUSGnHbyN6FQ==", "dependencies": { - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging.Configuration": "10.0.0", "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.0" } }, @@ -495,7 +336,6 @@ "resolved": "1.15.0", "contentHash": "OnuSUlRpGvowkOzGFQfy+KZFu0cITfKfh2IYJJiZskxVJiOuexwOOuvfDAgpJdmTzVWAHjYdz2shcHZaJ06UjQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", "OpenTelemetry.Api": "1.15.0" } }, @@ -509,8 +349,6 @@ "resolved": "8.4.2", "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.Extensions.Options": "8.0.0", "Polly.Core": "8.4.2" } }, @@ -519,8 +357,7 @@ "resolved": "8.4.2", "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", "dependencies": { - "Polly.Core": "8.4.2", - "System.Threading.RateLimiting": "8.0.0" + "Polly.Core": "8.4.2" } }, "Serilog": { @@ -533,9 +370,6 @@ "resolved": "10.0.0", "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", "Serilog": "4.3.0", "Serilog.Extensions.Logging": "10.0.0" } @@ -545,7 +379,6 @@ "resolved": "10.0.0", "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", "dependencies": { - "Microsoft.Extensions.Logging": "10.0.0", "Serilog": "4.2.0" } }, @@ -562,7 +395,6 @@ "resolved": "10.0.0", "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", "dependencies": { - "Microsoft.Extensions.Configuration.Binder": "10.0.0", "Microsoft.Extensions.DependencyModel": "10.0.0", "Serilog": "4.3.0" } @@ -587,10 +419,7 @@ "SQLitePCLRaw.core": { "type": "Transitive", "resolved": "2.1.11", - "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==", - "dependencies": { - "System.Memory": "4.5.3" - } + "contentHash": "PK0GLFkfhZzLQeR3PJf71FmhtHox+U3vcY6ZtswoMjrefkB9k6ErNJEnwXqc5KgXDSjige2XXrezqS39gkpQKA==" }, "SQLitePCLRaw.lib.e_sqlite3": { "type": "Transitive", @@ -605,30 +434,71 @@ "SQLitePCLRaw.core": "2.1.11" } }, - "System.Drawing.Common": { + "System.CodeDom": { "type": "Transitive", "resolved": "6.0.0", - "contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", "dependencies": { - "Microsoft.Win32.SystemEvents": "6.0.0" + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" } }, - "System.Memory": { + "System.Composition.Hosting": { "type": "Transitive", - "resolved": "4.5.3", - "contentHash": "3oDzvc/zzetpTKWMShs1AADwZjQ/36HnsufHRPcOjyRAAMLDlu2iD33MBI2opxnezcVUtXyqDXXjoFMOU9c7SA==" + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } }, - "System.Threading.RateLimiting": { + "System.Composition.Runtime": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Drawing.Common": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "NfuoKUiP2nUWwKZN6twGqXioIe1zVD0RIj2t976A+czLHr2nY454RwwXs6JU9Htc6mwqL6Dn/nEL3dpVf2jOhg==", + "dependencies": { + "Microsoft.Win32.SystemEvents": "6.0.0" + } }, "werkr.common": { "type": "Project", "dependencies": { "Google.Protobuf": "[3.34.0, )", - "Microsoft.AspNetCore.Authorization": "[10.0.3, )", - "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", "Microsoft.IdentityModel.Tokens": "[8.16.0, )", "Werkr.Common.Configuration": "[1.0.0, )" } @@ -671,8 +541,7 @@ "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", "dependencies": { "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" } }, "Google.Protobuf": { @@ -687,20 +556,7 @@ "resolved": "2.70.0", "contentHash": "xNv0FFCVJa5S1beUtye82WFCxKThuE1jbN8DO1x1Rj8VSIWXLBUmfSID5a1fGzsU2R/EMfwPoWclJ2RMfQuGXw==", "dependencies": { - "Grpc.Net.Common": "2.70.0", - "Microsoft.Extensions.Logging.Abstractions": "6.0.0" - } - }, - "Microsoft.AspNetCore.Authorization": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", - "dependencies": { - "Microsoft.AspNetCore.Metadata": "10.0.3", - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Grpc.Net.Common": "2.70.0" } }, "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { @@ -709,8 +565,7 @@ "resolved": "10.0.3", "contentHash": "6SEGWi35DZ9syBqCT8v5vEkm9tWUayWxVkHWLwW2FdyXSwS0zzEpIzGPLVQGeug3VU8d+hK/PFxFwwZnblv/zA==", "dependencies": { - "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.Identity.Stores": "10.0.3" + "Microsoft.EntityFrameworkCore.Relational": "10.0.3" } }, "Microsoft.AspNetCore.Identity.UI": { @@ -719,8 +574,7 @@ "resolved": "10.0.3", "contentHash": "xhxrP7QcUuyA2FcZsbvdHSqTauPseNrXzhFUYaRj+Elz1nxJceKbW+COc1P9QbpKeZDh9aTDSldHbz3AnMWOqg==", "dependencies": { - "Microsoft.Extensions.FileProviders.Embedded": "10.0.3", - "Microsoft.Extensions.Identity.Stores": "10.0.3" + "Microsoft.Extensions.FileProviders.Embedded": "10.0.3" } }, "Microsoft.Data.Sqlite.Core": { @@ -739,9 +593,7 @@ "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", "dependencies": { "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3" } }, "Microsoft.EntityFrameworkCore.Sqlite": { @@ -751,48 +603,11 @@ "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", "dependencies": { "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", "SQLitePCLRaw.core": "2.1.11" } }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" - } - }, - "Microsoft.Extensions.Configuration.Json": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" - } - }, - "Microsoft.Extensions.Hosting.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3" - } - }, "Microsoft.Extensions.Http.Resilience": { "type": "CentralTransitive", "requested": "[10.3.0, )", @@ -800,26 +615,15 @@ "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", "dependencies": { "Microsoft.Extensions.Http.Diagnostics": "10.3.0", - "Microsoft.Extensions.ObjectPool": "10.0.3", "Microsoft.Extensions.Resilience": "10.3.0" } }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" - } - }, "Microsoft.Extensions.ServiceDiscovery": { "type": "CentralTransitive", "requested": "[10.3.0, )", "resolved": "10.3.0", "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", "dependencies": { - "Microsoft.Extensions.Http": "10.0.3", "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" } }, @@ -829,7 +633,6 @@ "resolved": "8.16.0", "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", "Microsoft.IdentityModel.Logging": "8.16.0" } }, @@ -859,7 +662,6 @@ "resolved": "1.15.0", "contentHash": "RixjKyB1pbYGhWdvPto4KJs+exdQknJsnjUO9WszdLles5Vcd0EYzxPNJdwmLjYfP+Jfbr4B5nktM4ZgeHSWtg==", "dependencies": { - "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", "OpenTelemetry": "1.15.0" } } From b67ea43752d4de9ed5fc9af0fd8e10bc568d1b2e Mon Sep 17 00:00:00 2001 From: Taylor Marvin Date: Sat, 7 Mar 2026 23:53:39 -0800 Subject: [PATCH 10/37] Minor UI improvements --- .../Tasks/SuccessCriteriaEvaluatorTests.cs | 82 +++++++++++ .../Tasks/SuccessCriteriaEvaluator.cs | 25 ++-- src/Werkr.Core/Tasks/TaskService.cs | 5 +- src/Werkr.Server/Components/App.razor | 2 +- .../Components/Layout/NavMenu.razor | 32 +---- .../Components/Layout/NavMenu.razor.css | 48 +------ .../Components/Pages/Jobs/Detail.razor | 11 +- .../Components/Pages/Tasks/Create.razor | 28 ++-- .../Components/Pages/Tasks/Edit.razor | 30 ++-- .../Shared/ArgumentListEditor.razor | 65 +++++++++ .../Shared/SuccessCriteriaEditor.razor | 133 ++++++++++++++++++ .../Components/Shared/TagInput.razor | 57 +++----- src/Werkr.Server/wwwroot/app.css | 6 +- src/Werkr.Server/wwwroot/css/theme.css | 133 +++++++++++++----- src/Werkr.Server/wwwroot/js/toggle-theme.js | 31 ++-- 15 files changed, 494 insertions(+), 194 deletions(-) create mode 100644 src/Werkr.Server/Components/Shared/ArgumentListEditor.razor create mode 100644 src/Werkr.Server/Components/Shared/SuccessCriteriaEditor.razor diff --git a/src/Test/Werkr.Tests.Data/Unit/Tasks/SuccessCriteriaEvaluatorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Tasks/SuccessCriteriaEvaluatorTests.cs index cd21770..0d2cd80 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Tasks/SuccessCriteriaEvaluatorTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Tasks/SuccessCriteriaEvaluatorTests.cs @@ -207,6 +207,88 @@ public void ExitCodeCriteria_Null_Fails( ) { Assert.IsFalse( result ); } + // ── Explicit Criteria: exitCode == N (non-zero) ── + + /// + /// Verifies that "exitCode == 1" succeeds when exit code is 1. + /// + [TestMethod] + public void ExitCodeCriteria_One_Succeeds() + { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, + "exitCode == 1", + exitCode: 1, + output: [], + exception: null + ); + Assert.IsTrue(result); + } + + /// + /// Verifies that "exitCode == 1" fails when exit code is 0. + /// + [TestMethod] + public void ExitCodeCriteria_One_WhenZero_Fails() + { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, + "exitCode == 1", + exitCode: 0, + output: [], + exception: null + ); + Assert.IsFalse(result); + } + + /// + /// Verifies that "exitCode == -1" succeeds when exit code is -1. + /// + [TestMethod] + public void ExitCodeCriteria_NegativeOne_Succeeds() + { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, + "exitCode == -1", + exitCode: -1, + output: [], + exception: null + ); + Assert.IsTrue(result); + } + + /// + /// Verifies that "exitCode == 42" succeeds when exit code is 42. + /// + [TestMethod] + public void ExitCodeCriteria_FortyTwo_Succeeds() + { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, + "exitCode == 42", + exitCode: 42, + output: [], + exception: null + ); + Assert.IsTrue(result); + } + + /// + /// Verifies that "exitCode == 42" fails when exit code is 0. + /// + [TestMethod] + public void ExitCodeCriteria_FortyTwo_WhenZero_Fails() + { + bool result = _evaluator.Evaluate( + TaskActionType.ShellCommand, + "exitCode == 42", + exitCode: 0, + output: [], + exception: null + ); + Assert.IsFalse(result); + } + // ── Explicit Criteria: pwsh.HadErrors == false ── /// diff --git a/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs b/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs index d36e95e..fc14f04 100644 --- a/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs +++ b/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + using Microsoft.Extensions.Logging; using Werkr.Core.Communication; @@ -11,7 +13,11 @@ namespace Werkr.Core.Tasks; /// Custom criteria expressions are predefined string keys evaluated against the typed result. /// /// Logger instance. -public sealed class SuccessCriteriaEvaluator( ILogger logger ) { +public sealed partial class SuccessCriteriaEvaluator(ILogger logger) +{ + + [GeneratedRegex(@"^exitCode\s*==\s*(-?\d+)$", RegexOptions.IgnoreCase)] + private static partial Regex ExitCodePattern(); /// /// Evaluates success for a completed job. @@ -21,7 +27,7 @@ public sealed class SuccessCriteriaEvaluator( ILogger /// Optional criteria expression. When null, defaults are inferred from . /// Supported expressions: /// - /// exitCode == 0 — exit code must be zero + /// exitCode == N — exit code must equal the specified integer N /// pwsh.HadErrors == false — PowerShell had no errors /// output.contains("TEXT") — output must contain the specified text /// always — always succeed (useful for fire-and-forget tasks) @@ -117,16 +123,15 @@ private bool EvaluateExpression( return true; } - // "exitCode == 0" — exit code must be zero - if (string.Equals( - trimmed, - "exitCode == 0", - StringComparison.OrdinalIgnoreCase - )) { - bool success = exitCode.HasValue && exitCode.Value == 0; + // "exitCode == N" — exit code must equal the specified integer + Match exitCodeMatch = ExitCodePattern().Match(trimmed); + if (exitCodeMatch.Success && int.TryParse(exitCodeMatch.Groups[1].Value, out int expectedCode)) + { + bool success = exitCode.HasValue && exitCode.Value == expectedCode; if (!success && logger.IsEnabled( LogLevel.Debug )) { logger.LogDebug( - "Criteria 'exitCode == 0' failed: exitCode={ExitCode}.", + "Criteria 'exitCode == {Expected}' failed: exitCode={Actual}.", + expectedCode.ToString(), exitCode?.ToString( ) ?? "null" ); } diff --git a/src/Werkr.Core/Tasks/TaskService.cs b/src/Werkr.Core/Tasks/TaskService.cs index 308e488..1c6f51d 100644 --- a/src/Werkr.Core/Tasks/TaskService.cs +++ b/src/Werkr.Core/Tasks/TaskService.cs @@ -211,9 +211,8 @@ private static void Validate( WerkrTask task ) { throw new ValidationException( $"Invalid ActionType: {task.ActionType}." ); } - if (task.TargetTags.Length == 0) { - throw new ValidationException( "At least one target tag is required." ); - } + // Tags are optional — UI warns if empty, but execution proceeds. + // AgentResolver handles the case where no agents match. if (task.TimeoutMinutes.HasValue && task.TimeoutMinutes.Value <= 0) { throw new ValidationException( "TimeoutMinutes must be greater than zero." ); diff --git a/src/Werkr.Server/Components/App.razor b/src/Werkr.Server/Components/App.razor index 441bc6a..c05fcbd 100644 --- a/src/Werkr.Server/Components/App.razor +++ b/src/Werkr.Server/Components/App.razor @@ -25,7 +25,7 @@ -
- - +
+ +
@@ -139,7 +146,7 @@ @if ( _jobs is not null && _jobs.Count > 0 ) { @@ -246,6 +253,9 @@ } } + private static bool IsScriptType( string actionType ) => + actionType is "PowerShellScript" or "ShellScript"; + private void OnTargetTagsChanged( string[] tags ) { _targetTags = tags; if (_model is not null) { diff --git a/src/Werkr.Server/Components/Shared/ArgumentListEditor.razor b/src/Werkr.Server/Components/Shared/ArgumentListEditor.razor new file mode 100644 index 0000000..2e3646d --- /dev/null +++ b/src/Werkr.Server/Components/Shared/ArgumentListEditor.razor @@ -0,0 +1,65 @@ +@rendermode InteractiveServer + +
+ @for (int i = 0; i < _rows.Count; i++) { + int index = i; +
+ + +
+ } + +
+ +@code { + /// Newline-delimited arguments string — two-way bound. + [Parameter] + public string? Value { get; set; } + + /// Callback when value changes. + [Parameter] + public EventCallback ValueChanged { get; set; } + + private List _rows = []; + + protected override void OnParametersSet( ) { + // Only rebuild rows if Value actually changed (avoid clobbering during edits) + List incoming = string.IsNullOrWhiteSpace( Value ) + ? [] + : [.. Value.Split( '\n', StringSplitOptions.TrimEntries ) + .Where( s => s.Length > 0 )]; + + if (!_rows.SequenceEqual( incoming )) { + _rows = incoming; + } + } + + private async Task UpdateRow( int index, string value ) { + if (index >= 0 && index < _rows.Count) { + _rows[index] = value; + await NotifyChanged( ); + } + } + + private async Task RemoveRow( int index ) { + if (index >= 0 && index < _rows.Count) { + _rows.RemoveAt( index ); + await NotifyChanged( ); + } + } + + private async Task AddRow( ) { + _rows.Add( string.Empty ); + await NotifyChanged( ); + } + + private async Task NotifyChanged( ) { + List nonEmpty = [.. _rows.Where( r => !string.IsNullOrWhiteSpace( r ) )]; + string? result = nonEmpty.Count > 0 ? string.Join( "\n", nonEmpty ) : null; + Value = result; + await ValueChanged.InvokeAsync( result ); + } +} diff --git a/src/Werkr.Server/Components/Shared/SuccessCriteriaEditor.razor b/src/Werkr.Server/Components/Shared/SuccessCriteriaEditor.razor new file mode 100644 index 0000000..f9990a6 --- /dev/null +++ b/src/Werkr.Server/Components/Shared/SuccessCriteriaEditor.razor @@ -0,0 +1,133 @@ +@rendermode InteractiveServer + +
+ + + @if (_selectedOption == "exitCode") { +
+ exitCode == + +
+ @if (!string.IsNullOrEmpty( _exitCodeWarning )) { +
@_exitCodeWarning
+ } + } + + @if (_selectedOption == "outputContains") { +
+ output.contains + +
+ } +
+ +@code { + /// The criteria expression string — two-way bound. + [Parameter] + public string? Value { get; set; } + + /// Callback when value changes. + [Parameter] + public EventCallback ValueChanged { get; set; } + + /// Current action type — determines which options are available. + [Parameter] + public string ActionType { get; set; } = ""; + + private string _selectedOption = "auto"; + private string _exitCodeValue = "0"; + private string? _exitCodeWarning; + private string _outputText = ""; + private bool _initialized; + + protected override void OnParametersSet( ) { + if (!_initialized) { + ParseValue( Value ); + _initialized = true; + } + } + + private void ParseValue( string? value ) { + if (string.IsNullOrWhiteSpace( value )) { + _selectedOption = "auto"; + return; + } + + string trimmed = value.Trim( ); + + if (string.Equals( trimmed, "always", StringComparison.OrdinalIgnoreCase )) { + _selectedOption = "always"; + return; + } + + if (string.Equals( trimmed, "pwsh.HadErrors == false", StringComparison.OrdinalIgnoreCase )) { + _selectedOption = "hadErrors"; + return; + } + + // exitCode == N + if (trimmed.StartsWith( "exitCode", StringComparison.OrdinalIgnoreCase ) + && trimmed.Contains( "==" )) { + _selectedOption = "exitCode"; + string after = trimmed[( trimmed.IndexOf( "==" ) + 2 )..].Trim( ); + _exitCodeValue = after; + return; + } + + // output.contains("TEXT") + if (trimmed.StartsWith( "output.contains(", StringComparison.OrdinalIgnoreCase ) + && trimmed.EndsWith( ')' )) { + _selectedOption = "outputContains"; + string inner = trimmed["output.contains(".Length..^1]; + if (inner.Length >= 2 && inner[0] == '"' && inner[^1] == '"') { + inner = inner[1..^1]; + } + _outputText = inner; + return; + } + + // Unknown — treat as auto + _selectedOption = "auto"; + } + + private async Task OnOptionChanged( ChangeEventArgs e ) { + _selectedOption = e.Value?.ToString( ) ?? "auto"; + _exitCodeWarning = null; + await EmitValue( ); + } + + private async Task OnExitCodeChanged( ChangeEventArgs e ) { + _exitCodeValue = e.Value?.ToString( ) ?? "0"; + _exitCodeWarning = int.TryParse( _exitCodeValue, out _ ) ? null : "Must be a number"; + await EmitValue( ); + } + + private async Task OnOutputTextChanged( ChangeEventArgs e ) { + _outputText = e.Value?.ToString( ) ?? ""; + await EmitValue( ); + } + + private async Task EmitValue( ) { + string? result = _selectedOption switch { + "exitCode" => $"exitCode == {_exitCodeValue}", + "hadErrors" => "pwsh.HadErrors == false", + "outputContains" => $"output.contains(\"{_outputText}\")", + "always" => "always", + _ => null, // auto clears the field + }; + Value = result; + await ValueChanged.InvokeAsync( result ); + } +} diff --git a/src/Werkr.Server/Components/Shared/TagInput.razor b/src/Werkr.Server/Components/Shared/TagInput.razor index 85cf090..95ef138 100644 --- a/src/Werkr.Server/Components/Shared/TagInput.razor +++ b/src/Werkr.Server/Components/Shared/TagInput.razor @@ -10,30 +10,29 @@ } @if (Value.Length == 0) { - No tags assigned. +
+ ⚠ No target tags assigned. This task won't be dispatched to any agent. +
}
-
+@if (FilteredSuggestions.Count > 0) { + + +} + +
- + @onkeydown="OnKeyDown" />
- - @if (_showSuggestions && FilteredSuggestions.Count > 0) { -
- @foreach (string suggestion in FilteredSuggestions) { - - } -
- }
@code { @@ -46,19 +45,11 @@ public EventCallback ValueChanged { get; set; } private string _inputText = string.Empty; - private bool _showSuggestions; private List _allTags = []; private List FilteredSuggestions { get { - if (string.IsNullOrWhiteSpace( _inputText )) { - // Show all tags not already selected - return [.. _allTags.Where( t => !Value.Contains( t, StringComparer.OrdinalIgnoreCase ) )]; - } - - return [.. _allTags - .Where( t => t.Contains( _inputText, StringComparison.OrdinalIgnoreCase ) - && !Value.Contains( t, StringComparer.OrdinalIgnoreCase ) )]; + return [.. _allTags.Where( t => !Value.Contains( t, StringComparer.OrdinalIgnoreCase ) )]; } } @@ -71,21 +62,9 @@ } } - private void ShowSuggestions( ) { - _showSuggestions = true; - } - - private async Task OnBlurAsync( ) { - // Delay to allow click on suggestion to register - await Task.Delay( 200 ); - _showSuggestions = false; - } - private async Task OnKeyDown( KeyboardEventArgs e ) { if (e.Key == "Enter") { await AddCurrentTag( ); - } else if (e.Key == "Escape") { - _showSuggestions = false; } } @@ -97,7 +76,6 @@ } _inputText = string.Empty; - _showSuggestions = false; string[] updated = [.. Value, trimmed]; Value = updated; await ValueChanged.InvokeAsync( updated ); @@ -115,7 +93,6 @@ } _inputText = string.Empty; - _showSuggestions = false; string[] updated = [.. Value, tag]; Value = updated; await ValueChanged.InvokeAsync( updated ); diff --git a/src/Werkr.Server/wwwroot/app.css b/src/Werkr.Server/wwwroot/app.css index 6d43780..f3fd62b 100644 --- a/src/Werkr.Server/wwwroot/app.css +++ b/src/Werkr.Server/wwwroot/app.css @@ -65,15 +65,15 @@ h1:focus { } .valid.modified:not([type=checkbox]) { - outline: 1px solid #26b050; + outline: 1px solid var(--green, #26b050); } .invalid { - outline: 1px solid #e50050; + outline: 1px solid var(--red, #e50050); } .validation-message { - color: #e50050; + color: var(--red, #e50050); } .blazor-error-boundary { diff --git a/src/Werkr.Server/wwwroot/css/theme.css b/src/Werkr.Server/wwwroot/css/theme.css index cb39e92..cffb389 100644 --- a/src/Werkr.Server/wwwroot/css/theme.css +++ b/src/Werkr.Server/wwwroot/css/theme.css @@ -243,54 +243,111 @@ body.dark-theme .badge.bg-secondary { background-color: var(--grey-700) !important; } -/* ── Theme toggle switch ──────────────────────────────────── */ -.theme-toggle { - display: inline-flex; - align-items: center; - gap: 0.4rem; - font-size: 0.85rem; - user-select: none; +/* ── Light Theme — Bootstrap component overrides ──────────── */ + +body.light-theme .alert-info { + --bs-alert-bg: #cff4fc; + --bs-alert-color: #055160; + --bs-alert-border-color: #9eeaf9; } -.theme-toggle .icon { - font-size: 0.9rem; - line-height: 1; +body.light-theme .alert-danger { + --bs-alert-bg: #f8d7da; + --bs-alert-color: #842029; + --bs-alert-border-color: #f5c2c7; +} + +body.light-theme .alert-warning { + --bs-alert-bg: #fff3cd; + --bs-alert-color: #664d03; + --bs-alert-border-color: #ffe69c; +} + +body.light-theme .alert-success { + --bs-alert-bg: #d1e7dd; + --bs-alert-color: #0f5132; + --bs-alert-border-color: #badbcc; +} + +body.light-theme .text-muted { + color: var(--text-muted-color) !important; +} + +body.light-theme code { + color: #d63384; +} + +body.light-theme .form-check-input { + background-color: var(--input-bg); + border-color: var(--input-border); +} + +body.light-theme .form-check-input:checked { + background-color: var(--btn-primary-bg); + border-color: var(--btn-primary-bg); +} + +body.light-theme .btn-outline-primary { + --bs-btn-color: #0d6efd; + --bs-btn-border-color: #0d6efd; + --bs-btn-hover-bg: #0d6efd; + --bs-btn-hover-border-color: #0d6efd; + --bs-btn-hover-color: #fff; +} + +body.light-theme .btn-outline-secondary { + --bs-btn-color: #6c757d; + --bs-btn-border-color: #6c757d; + --bs-btn-hover-bg: #6c757d; + --bs-btn-hover-border-color: #6c757d; + --bs-btn-hover-color: #fff; } -.theme-toggle .switch { - position: relative; - display: inline-block; - width: 36px; - height: 18px; +body.light-theme .btn-outline-danger { + --bs-btn-color: #dc3545; + --bs-btn-border-color: #dc3545; + --bs-btn-hover-bg: #dc3545; + --bs-btn-hover-border-color: #dc3545; + --bs-btn-hover-color: #fff; } -.theme-toggle .switch input { - opacity: 0; - width: 0; - height: 0; +body.light-theme .table-dark { + --bs-table-bg: #212529; + --bs-table-color: #fff; + --bs-table-border-color: #373b3e; } -.theme-toggle .slider { - position: absolute; - cursor: pointer; - top: 0; left: 0; right: 0; bottom: 0; - background-color: var(--green); - transition: .3s; - border-radius: 18px; +body.light-theme .badge.bg-secondary { + background-color: var(--badge-bg) !important; } -.theme-toggle .slider::before { - content: ""; - position: absolute; - height: 12px; - width: 12px; - left: 3px; - bottom: 3px; - background-color: white; - transition: .3s; - border-radius: 50%; +body.light-theme .list-group-item { + background-color: var(--card-bg); + border-color: var(--card-border); + color: var(--body-content-color); +} + +body.light-theme .breadcrumb { + color: var(--body-content-color); +} + +body.light-theme fieldset { + border-color: var(--card-border); } -.theme-toggle .switch input:checked + .slider::before { - transform: translateX(18px); +body.light-theme legend { + border-color: var(--card-border); +} +/* ── Theme toggle switch ──────────────────────────────────── */ +.theme-toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + user-select: none; +} + +.theme-toggle .icon { + font-size: 0.9rem; + line-height: 1; } diff --git a/src/Werkr.Server/wwwroot/js/toggle-theme.js b/src/Werkr.Server/wwwroot/js/toggle-theme.js index 9d31693..e2d9ad6 100644 --- a/src/Werkr.Server/wwwroot/js/toggle-theme.js +++ b/src/Werkr.Server/wwwroot/js/toggle-theme.js @@ -12,24 +12,35 @@ b.classList.toggle("light-theme", !dark); } - // Read stored preference (default: dark) - const stored = window.localStorage?.getItem("theme"); - const isDark = !stored || stored === "dark-theme"; - applyTheme(isDark); + function currentIsDark() { + const stored = window.localStorage?.getItem("theme"); + return !stored || stored === "dark-theme"; + } - // Bind the toggle checkbox - function bindToggle(sw) { + // Bind the toggle checkbox — re-entrant safe + function bindToggle() { + const sw = document.getElementById("theme-toggle"); if (!sw) return; - sw.checked = isDark; - sw.addEventListener("change", function () { + sw.checked = currentIsDark(); + // Clone-replace to remove any previous listener + const fresh = sw.cloneNode(true); + sw.parentNode.replaceChild(fresh, sw); + fresh.addEventListener("change", function () { const dark = this.checked; applyTheme(dark); localStorage?.setItem("theme", dark ? "dark-theme" : "light-theme"); }); } - // The checkbox should already exist (static SSR) - bindToggle(document.getElementById("theme-toggle")); + // Initial page load + applyTheme(currentIsDark()); + bindToggle(); + + // Re-apply after Blazor enhanced navigation replaces the DOM + document.addEventListener("blazor:enhancedload", function () { + applyTheme(currentIsDark()); + bindToggle(); + }); // Sync across tabs window.addEventListener("storage", function (e) { From 2c9b344e61ed76f2ea44f17e45882dde3700575a Mon Sep 17 00:00:00 2001 From: Taylor Marvin Date: Sun, 8 Mar 2026 20:22:58 -0700 Subject: [PATCH 11/37] feat: unified execution data model and UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data model: - Add TaskSchedule/WorkflowSchedule many-to-many join entities with composite PKs, IsOneTime flag, and CreatedAtUtc - Remove ScheduleId FK from WerkrTask and Workflow entities - Add CatchUpEnabled to DbSchedule for missed-occurrence detection - Add ScheduleId to WerkrJob for per-schedule catch-up tracking - Add IsEphemeral to WerkrTask for console-created task cleanup - Configure new relationships and DbSets in WerkrDbContext - Update Sqlite migration snapshot UI (Blazor Server): - Remove schedule dropdown/columns from Task and Workflow CRUD pages and dashboard - Update Console.razor to map OperatorType → TaskActionType and handle meta/complete SSE events for unified execution path - Fix sidebar layout: flex-based nav-scrollable, move footer outside scrollable region - Use card class on ChangePassword guidance box - Remove unused import and version text from NavMenu --- src/Installer/Msi/Agent/packages.lock.json | 6 +- src/Installer/Msi/Server/packages.lock.json | 6 +- .../Services/PwshServiceTests.cs | 131 -- .../Services/SystemShellServiceTests.cs | 131 -- .../Communication/CommandDispatcherTests.cs | 236 --- .../Unit/Communication/NullEncryptionTests.cs | 17 - .../Tasks/SuccessCriteriaEvaluatorTests.cs | 25 +- .../Unit/Workflows/WorkflowExecutorTests.cs | 1581 ----------------- .../Communication/AgentGrpcClientFactory.cs | 9 +- src/Werkr.Agent/Program.cs | 12 +- src/Werkr.Agent/Protos/Action.proto | 20 - src/Werkr.Agent/Protos/Shell.proto | 34 - .../Scheduling/ScheduleEvaluatorService.cs | 388 +++- .../Scheduling/WorkflowExecutionService.cs | 733 ++++++++ src/Werkr.Agent/Services/ActionService.cs | 59 - .../Services/OutputStreamingService.cs | 219 +++ src/Werkr.Agent/Services/PwshService.cs | 101 -- .../Services/SystemShellService.cs | 101 -- src/Werkr.Agent/Werkr.Agent.csproj | 6 +- src/Werkr.Api/Endpoints/AgentEndpoints.cs | 40 +- src/Werkr.Api/Endpoints/EventEndpoints.cs | 52 + src/Werkr.Api/Endpoints/ShellEndpoints.cs | 117 +- src/Werkr.Api/Endpoints/TaskEndpoints.cs | 33 +- src/Werkr.Api/Endpoints/WorkflowEndpoints.cs | 77 +- src/Werkr.Api/Models/TaskMapper.cs | 3 - src/Werkr.Api/Models/WorkflowMapper.cs | 3 - src/Werkr.Api/Program.cs | 18 +- .../Services/JobReportingGrpcService.cs | 83 +- .../Services/OutputStreamingGrpcService.cs | 147 ++ .../ScheduleInvalidationDispatcher.cs | 5 +- .../Services/ScheduleSyncGrpcService.cs | 33 +- .../Services/WorkflowExecutionGrpcService.cs | 110 -- src/Werkr.Api/Werkr.Api.csproj | 4 +- src/Werkr.AppHost/AppHost.cs | 8 +- .../Models/ExecuteCommandRequest.cs | 14 +- src/Werkr.Common/Models/TaskCreateRequest.cs | 1 - src/Werkr.Common/Models/TaskDto.cs | 1 - src/Werkr.Common/Models/TaskUpdateRequest.cs | 1 - .../Models/WorkflowCreateRequest.cs | 3 +- src/Werkr.Common/Models/WorkflowDto.cs | 1 - .../Models/WorkflowUpdateRequest.cs | 3 +- src/Werkr.Common/Protos/JobReport.proto | 4 + src/Werkr.Common/Protos/OutputStreaming.proto | 40 + src/Werkr.Common/Protos/ScheduleSync.proto | 1 + .../Protos/WorkflowExecution.proto | 28 - .../Communication/CommandDispatchFailure.cs | 24 - .../Communication/CommandDispatcher.cs | 438 ----- .../CommandDispatcherException.cs | 52 - .../Communication/GrpcOutputReader.cs | 49 - .../Communication/ICommandDispatcher.cs | 54 - src/Werkr.Core/Communication/JobEvent.cs | 24 + .../Communication/JobEventBroadcaster.cs | 90 + .../Communication/JobEventSubscription.cs | 34 + src/Werkr.Core/Scheduling/RunNowService.cs | 171 ++ src/Werkr.Core/Tasks/JobExecutionService.cs | 357 +--- .../Tasks/SuccessCriteriaEvaluator.cs | 12 +- src/Werkr.Core/Tasks/TaskService.cs | 1 - src/Werkr.Core/Werkr.Core.csproj | 2 - src/Werkr.Core/Workflows/WorkflowExecutor.cs | 561 ------ .../Workflows/WorkflowRunTracker.cs | 58 - src/Werkr.Core/Workflows/WorkflowService.cs | 7 +- ... 20260309015501_InitialCreate.Designer.cs} | 2 +- ...ate.cs => 20260309015501_InitialCreate.cs} | 1 + ... 20260309015522_InitialCreate.Designer.cs} | 2 +- ...ate.cs => 20260309015522_InitialCreate.cs} | 1 + .../Entities/Schedule/DbSchedule.cs | 13 + src/Werkr.Data/Entities/Tasks/TaskSchedule.cs | 34 + src/Werkr.Data/Entities/Tasks/WerkrJob.cs | 11 + src/Werkr.Data/Entities/Tasks/WerkrTask.cs | 12 +- src/Werkr.Data/Entities/Workflows/Workflow.cs | 11 +- .../Entities/Workflows/WorkflowSchedule.cs | 34 + ... 20260309015413_InitialCreate.Designer.cs} | 152 +- ...ate.cs => 20260309015413_InitialCreate.cs} | 196 +- .../PostgresWerkrDbContextModelSnapshot.cs | 150 +- ... 20260309015437_InitialCreate.Designer.cs} | 152 +- ...ate.cs => 20260309015437_InitialCreate.cs} | 180 +- .../SqliteWerkrDbContextModelSnapshot.cs | 150 +- src/Werkr.Data/WerkrDbContext.cs | 36 + .../Components/Layout/MainLayout.razor.css | 2 + .../Components/Layout/NavMenu.razor | 25 +- .../Components/Layout/NavMenu.razor.css | 12 +- .../Pages/Account/ChangePassword.razor | 2 +- .../Components/Pages/Agents/Console.razor | 42 +- .../Components/Pages/Dashboard/TaskList.razor | 56 - .../Components/Pages/Tasks/Create.razor | 14 - .../Components/Pages/Tasks/Edit.razor | 17 +- .../Components/Pages/Workflows/Create.razor | 10 +- .../Components/Pages/Workflows/Edit.razor | 13 +- .../Components/Pages/Workflows/Index.razor | 2 - 89 files changed, 3174 insertions(+), 4737 deletions(-) delete mode 100644 src/Test/Werkr.Tests.Agent/Services/PwshServiceTests.cs delete mode 100644 src/Test/Werkr.Tests.Agent/Services/SystemShellServiceTests.cs delete mode 100644 src/Test/Werkr.Tests.Data/Unit/Communication/CommandDispatcherTests.cs delete mode 100644 src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowExecutorTests.cs delete mode 100644 src/Werkr.Agent/Protos/Action.proto delete mode 100644 src/Werkr.Agent/Protos/Shell.proto create mode 100644 src/Werkr.Agent/Scheduling/WorkflowExecutionService.cs delete mode 100644 src/Werkr.Agent/Services/ActionService.cs create mode 100644 src/Werkr.Agent/Services/OutputStreamingService.cs delete mode 100644 src/Werkr.Agent/Services/PwshService.cs delete mode 100644 src/Werkr.Agent/Services/SystemShellService.cs create mode 100644 src/Werkr.Api/Endpoints/EventEndpoints.cs create mode 100644 src/Werkr.Api/Services/OutputStreamingGrpcService.cs delete mode 100644 src/Werkr.Api/Services/WorkflowExecutionGrpcService.cs create mode 100644 src/Werkr.Common/Protos/OutputStreaming.proto delete mode 100644 src/Werkr.Common/Protos/WorkflowExecution.proto delete mode 100644 src/Werkr.Core/Communication/CommandDispatchFailure.cs delete mode 100644 src/Werkr.Core/Communication/CommandDispatcher.cs delete mode 100644 src/Werkr.Core/Communication/CommandDispatcherException.cs delete mode 100644 src/Werkr.Core/Communication/GrpcOutputReader.cs delete mode 100644 src/Werkr.Core/Communication/ICommandDispatcher.cs create mode 100644 src/Werkr.Core/Communication/JobEvent.cs create mode 100644 src/Werkr.Core/Communication/JobEventBroadcaster.cs create mode 100644 src/Werkr.Core/Communication/JobEventSubscription.cs create mode 100644 src/Werkr.Core/Scheduling/RunNowService.cs delete mode 100644 src/Werkr.Core/Workflows/WorkflowExecutor.cs delete mode 100644 src/Werkr.Core/Workflows/WorkflowRunTracker.cs rename src/Werkr.Data.Identity/Migrations/Postgres/{20260308034043_InitialCreate.Designer.cs => 20260309015501_InitialCreate.Designer.cs} (99%) rename src/Werkr.Data.Identity/Migrations/Postgres/{20260308034043_InitialCreate.cs => 20260309015501_InitialCreate.cs} (99%) rename src/Werkr.Data.Identity/Migrations/Sqlite/{20260308034120_InitialCreate.Designer.cs => 20260309015522_InitialCreate.Designer.cs} (99%) rename src/Werkr.Data.Identity/Migrations/Sqlite/{20260308034120_InitialCreate.cs => 20260309015522_InitialCreate.cs} (99%) create mode 100644 src/Werkr.Data/Entities/Tasks/TaskSchedule.cs create mode 100644 src/Werkr.Data/Entities/Workflows/WorkflowSchedule.cs rename src/Werkr.Data/Migrations/Postgres/{20260308033431_InitialCreate.Designer.cs => 20260309015413_InitialCreate.Designer.cs} (92%) rename src/Werkr.Data/Migrations/Postgres/{20260308033431_InitialCreate.cs => 20260309015413_InitialCreate.cs} (91%) rename src/Werkr.Data/Migrations/Sqlite/{20260308033950_InitialCreate.Designer.cs => 20260309015437_InitialCreate.Designer.cs} (92%) rename src/Werkr.Data/Migrations/Sqlite/{20260308033950_InitialCreate.cs => 20260309015437_InitialCreate.cs} (91%) diff --git a/src/Installer/Msi/Agent/packages.lock.json b/src/Installer/Msi/Agent/packages.lock.json index f432b8b..d8ec7ee 100644 --- a/src/Installer/Msi/Agent/packages.lock.json +++ b/src/Installer/Msi/Agent/packages.lock.json @@ -14,6 +14,10 @@ "resolved": "6.0.2", "contentHash": "plP64ub/0KjNbtLeaeiibVCPkKfr439WTKZmTwVSoQ4fznLHBZLsE0+wcyk6dA5cQuQsD5hlmnVGTKgPioiusQ==" } - } + }, + "native,Version=v0.0/win": {}, + "native,Version=v0.0/win-arm64": {}, + "native,Version=v0.0/win-x64": {}, + "native,Version=v0.0/win-x86": {} } } \ No newline at end of file diff --git a/src/Installer/Msi/Server/packages.lock.json b/src/Installer/Msi/Server/packages.lock.json index f432b8b..d8ec7ee 100644 --- a/src/Installer/Msi/Server/packages.lock.json +++ b/src/Installer/Msi/Server/packages.lock.json @@ -14,6 +14,10 @@ "resolved": "6.0.2", "contentHash": "plP64ub/0KjNbtLeaeiibVCPkKfr439WTKZmTwVSoQ4fznLHBZLsE0+wcyk6dA5cQuQsD5hlmnVGTKgPioiusQ==" } - } + }, + "native,Version=v0.0/win": {}, + "native,Version=v0.0/win-arm64": {}, + "native,Version=v0.0/win-x64": {}, + "native,Version=v0.0/win-x86": {} } } \ No newline at end of file diff --git a/src/Test/Werkr.Tests.Agent/Services/PwshServiceTests.cs b/src/Test/Werkr.Tests.Agent/Services/PwshServiceTests.cs deleted file mode 100644 index bf87da1..0000000 --- a/src/Test/Werkr.Tests.Agent/Services/PwshServiceTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -using Grpc.Core; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using Werkr.Agent.Operators; -using Werkr.Agent.Protos; -using Werkr.Agent.Services; -using Werkr.Common.Configuration; -using Werkr.Common.Protos; -using Werkr.Core.Communication; -using Werkr.Core.Cryptography; -using Werkr.Data.Entities.Registration; -using Werkr.Tests.Agent.Helpers; - -namespace Werkr.Tests.Agent.Services; - -/// -/// Unit tests for the gRPC service. Validates that a valid encrypted -/// produces encrypted output, that the service rejects -/// requests when PowerShell is disabled (), and that a missing -/// results in . -/// -[TestClass] -public class PwshServiceTests { - /// - /// Gets or sets the MSTest for cancellation token access. - /// - public TestContext TestContext { get; set; } = null!; - - /// - /// Sends a valid encrypted command, verifies the service writes encrypted output, and asserts the - /// decrypted stream contains the expected command output. - /// - [TestMethod] - public async Task RunCommand_ValidEncryptedRequest_WritesEncryptedOutput( ) { - byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); - RegisteredConnection connection = CreateConnection( sharedKey ); - string keyId = connection.Id.ToString( ); - - PwshService service = new( - new PwshOperator( Options.Create( new AgentSettings { EnablePowerShell = true } ), NullLogger.Instance ), - Options.Create( new AgentSettings { EnablePowerShell = true } ), - NullLogger.Instance ); - - ShellRequest innerRequest = new( ) { Command = "Write-Output 'pwsh-service-test'" }; - EncryptedEnvelope request = PayloadEncryptor.EncryptToEnvelope( innerRequest, sharedKey, keyId ); - - MockServerStreamWriter stream = new( ); - TestServerCallContext context = TestServerCallContext.Create( cancellationToken: TestContext.CancellationToken ); - context.ExposedUserState["Connection"] = connection; - - await service.RunCommand( request, stream, context ); - - List decryptedMessages = [.. stream.Messages.Select( envelope => { - GrpcLogMsg logMsg = PayloadEncryptor.DecryptFromEnvelope( envelope, sharedKey ); - return logMsg.Message; - } )]; - - Assert.AreNotEqual( -1, decryptedMessages.FindIndex( m => m.Contains( "pwsh-service-test", StringComparison.OrdinalIgnoreCase ) ) ); - } - - /// - /// Verifies that invoking when PowerShell is disabled throws an - /// with . - /// - [TestMethod] - public async Task RunCommand_WhenPowerShellDisabled_ThrowsUnimplemented( ) { - byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); - RegisteredConnection connection = CreateConnection( sharedKey ); - string keyId = connection.Id.ToString( ); - - PwshService service = new( - new PwshOperator( Options.Create( new AgentSettings { EnablePowerShell = false } ), NullLogger.Instance ), - Options.Create( new AgentSettings { EnablePowerShell = false } ), - NullLogger.Instance ); - - ShellRequest innerRequest = new( ) { Command = "Write-Output 'x'" }; - EncryptedEnvelope request = PayloadEncryptor.EncryptToEnvelope( innerRequest, sharedKey, keyId ); - - MockServerStreamWriter stream = new( ); - TestServerCallContext context = TestServerCallContext.Create( cancellationToken: TestContext.CancellationToken ); - context.ExposedUserState["Connection"] = connection; - - RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => - await service.RunCommand( request, stream, context ) ); - - Assert.AreEqual( StatusCode.Unimplemented, ex.StatusCode ); - } - - /// - /// Verifies that calling without a - /// in the user state throws an with - /// . - /// - [TestMethod] - public async Task RunCommand_MissingConnection_ThrowsInternal( ) { - byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); - string keyId = Guid.NewGuid( ).ToString( ); - - PwshService service = new( - new PwshOperator( Options.Create( new AgentSettings { EnablePowerShell = true } ), NullLogger.Instance ), - Options.Create( new AgentSettings { EnablePowerShell = true } ), - NullLogger.Instance ); - - ShellRequest innerRequest = new( ) { Command = "Write-Output 'x'" }; - EncryptedEnvelope request = PayloadEncryptor.EncryptToEnvelope( innerRequest, sharedKey, keyId ); - - MockServerStreamWriter stream = new( ); - TestServerCallContext context = TestServerCallContext.Create( cancellationToken: TestContext.CancellationToken ); - - RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => - await service.RunCommand( request, stream, context ) ); - - Assert.AreEqual( StatusCode.Internal, ex.StatusCode ); - } - - /// - /// Creates a entity pre-loaded with the supplied shared key. - /// - private static RegisteredConnection CreateConnection( byte[] sharedKey ) { - return new RegisteredConnection { - Id = Guid.NewGuid( ), - ConnectionName = "Agent", - RemoteUrl = "https://localhost:5100", - OutboundApiKey = "outbound", - InboundApiKeyHash = "inbound", - SharedKey = sharedKey, - IsServer = false, - Status = Werkr.Common.Models.ConnectionStatus.Connected, - }; - } -} diff --git a/src/Test/Werkr.Tests.Agent/Services/SystemShellServiceTests.cs b/src/Test/Werkr.Tests.Agent/Services/SystemShellServiceTests.cs deleted file mode 100644 index 117702c..0000000 --- a/src/Test/Werkr.Tests.Agent/Services/SystemShellServiceTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -using Grpc.Core; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using Werkr.Agent.Operators; -using Werkr.Agent.Protos; -using Werkr.Agent.Services; -using Werkr.Common.Configuration; -using Werkr.Common.Protos; -using Werkr.Core.Communication; -using Werkr.Core.Cryptography; -using Werkr.Data.Entities.Registration; -using Werkr.Tests.Agent.Helpers; - -namespace Werkr.Tests.Agent.Services; - -/// -/// Unit tests for the gRPC service. Validates that a valid encrypted -/// produces encrypted output, that the service rejects -/// requests when the system shell is disabled (), and that a missing -/// results in . -/// -[TestClass] -public class SystemShellServiceTests { - /// - /// Gets or sets the MSTest for cancellation token access. - /// - public TestContext TestContext { get; set; } = null!; - - /// - /// Sends a valid encrypted command, verifies the service writes encrypted output, and asserts the - /// decrypted stream contains the expected shell output. - /// - [TestMethod] - public async Task RunCommand_ValidEncryptedRequest_WritesEncryptedOutput( ) { - byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); - RegisteredConnection connection = CreateConnection( sharedKey ); - string keyId = connection.Id.ToString( ); - - SystemShellService service = new( - new SystemShellOperator( NullLogger.Instance ), - Options.Create( new AgentSettings { EnableSystemShell = true } ), - NullLogger.Instance ); - - ShellRequest innerRequest = new( ) { Command = "echo shell-service-test" }; - EncryptedEnvelope request = PayloadEncryptor.EncryptToEnvelope( innerRequest, sharedKey, keyId ); - - MockServerStreamWriter stream = new( ); - TestServerCallContext context = TestServerCallContext.Create( cancellationToken: TestContext.CancellationToken ); - context.ExposedUserState["Connection"] = connection; - - await service.RunCommand( request, stream, context ); - - List decryptedMessages = [.. stream.Messages.Select( envelope => { - GrpcLogMsg logMsg = PayloadEncryptor.DecryptFromEnvelope( envelope, sharedKey ); - return logMsg.Message; - } )]; - - Assert.AreNotEqual( -1, decryptedMessages.FindIndex( m => m.Contains( "shell-service-test", StringComparison.OrdinalIgnoreCase ) ) ); - } - - /// - /// Verifies that invoking when the system shell is disabled - /// throws an with . - /// - [TestMethod] - public async Task RunCommand_WhenSystemShellDisabled_ThrowsUnimplemented( ) { - byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); - RegisteredConnection connection = CreateConnection( sharedKey ); - string keyId = connection.Id.ToString( ); - - SystemShellService service = new( - new SystemShellOperator( NullLogger.Instance ), - Options.Create( new AgentSettings { EnableSystemShell = false } ), - NullLogger.Instance ); - - ShellRequest innerRequest = new( ) { Command = "echo x" }; - EncryptedEnvelope request = PayloadEncryptor.EncryptToEnvelope( innerRequest, sharedKey, keyId ); - - MockServerStreamWriter stream = new( ); - TestServerCallContext context = TestServerCallContext.Create( cancellationToken: TestContext.CancellationToken ); - context.ExposedUserState["Connection"] = connection; - - RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => - await service.RunCommand( request, stream, context ) ); - - Assert.AreEqual( StatusCode.Unimplemented, ex.StatusCode ); - } - - /// - /// Verifies that calling without a - /// in the user state throws an - /// with . - /// - [TestMethod] - public async Task RunCommand_MissingConnection_ThrowsInternal( ) { - byte[] sharedKey = EncryptionProvider.GenerateRandomBytes( 32 ); - string keyId = Guid.NewGuid( ).ToString( ); - - SystemShellService service = new( - new SystemShellOperator( NullLogger.Instance ), - Options.Create( new AgentSettings { EnableSystemShell = true } ), - NullLogger.Instance ); - - ShellRequest innerRequest = new( ) { Command = "echo x" }; - EncryptedEnvelope request = PayloadEncryptor.EncryptToEnvelope( innerRequest, sharedKey, keyId ); - - MockServerStreamWriter stream = new( ); - TestServerCallContext context = TestServerCallContext.Create( cancellationToken: TestContext.CancellationToken ); - - RpcException ex = await Assert.ThrowsExactlyAsync( async ( ) => - await service.RunCommand( request, stream, context ) ); - - Assert.AreEqual( StatusCode.Internal, ex.StatusCode ); - } - - /// - /// Creates a entity pre-loaded with the supplied shared key. - /// - private static RegisteredConnection CreateConnection( byte[] sharedKey ) { - return new RegisteredConnection { - Id = Guid.NewGuid( ), - ConnectionName = "Agent", - RemoteUrl = "https://localhost:5100", - OutboundApiKey = "outbound", - InboundApiKeyHash = "inbound", - SharedKey = sharedKey, - IsServer = false, - Status = Werkr.Common.Models.ConnectionStatus.Connected, - }; - } -} diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/CommandDispatcherTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/CommandDispatcherTests.cs deleted file mode 100644 index ae04ed2..0000000 --- a/src/Test/Werkr.Tests.Data/Unit/Communication/CommandDispatcherTests.cs +++ /dev/null @@ -1,236 +0,0 @@ -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Werkr.Common.Models; -using Werkr.Core.Communication; -using Werkr.Core.Cryptography; -using Werkr.Core.Cryptography.KeyInfo; -using Werkr.Data; -using Werkr.Data.Entities.Registration; - -namespace Werkr.Tests.Data.Unit.Communication; - -/// -/// Contains unit tests for the class defined in Werkr.Core. Validates that unsupported -/// values produce appropriate error outputs for both command and script execution paths. -/// -[TestClass] -public class CommandDispatcherTests { - /// - /// The in-memory SQLite connection kept open for the duration of each test. - /// - private SqliteConnection _connection = null!; - /// - /// The used for seeding and querying test data. - /// - private SqliteWerkrDbContext _dbContext = null!; - /// - /// The service provider supplying scoped instances. - /// - private ServiceProvider _serviceProvider = null!; - /// - /// The managing gRPC channels for test dispatching. - /// - private AgentConnectionManager _connectionManager = null!; - /// - /// The instance under test. - /// - private CommandDispatcher _dispatcher = null!; - - /// - /// Gets or sets the MSTest providing per-test cancellation tokens and metadata. - /// - public TestContext TestContext { get; set; } = null!; - - /// - /// Creates an in-memory SQLite database, registers required services, and constructs the under test. - /// - [TestInitialize] - public void TestInit( ) { - _connection = new SqliteConnection( "DataSource=:memory:" ); - _connection.Open( ); - - DbContextOptions options = new DbContextOptionsBuilder( ) - .UseSqlite( _connection ) - .Options; - - _dbContext = new SqliteWerkrDbContext( options ); - _ = _dbContext.Database.EnsureCreated( ); - - ServiceCollection services = new( ); - _ = services.AddDbContext( - b => b.UseSqlite( _connection ), - ServiceLifetime.Scoped - ); - _ = services.AddDbContext( - b => b.UseSqlite( _connection ), - ServiceLifetime.Scoped - ); - - _serviceProvider = services.BuildServiceProvider( ); - - _connectionManager = new AgentConnectionManager( - _serviceProvider.GetRequiredService( ), - NullLogger.Instance - ); - - _dispatcher = new CommandDispatcher( - _connectionManager, - NullLogger.Instance - ); - } - - /// - /// Disposes the connection manager, service provider, database context, and SQLite connection. - /// - [TestCleanup] - public void TestCleanup( ) { - _connectionManager.Dispose( ); - _serviceProvider.Dispose( ); - _dbContext.Dispose( ); - _connection.Dispose( ); - } - - /// - /// Verifies that returns a single error output when an unsupported is provided. - /// - [TestMethod] - public async Task ExecuteCommandAsync_UnsupportedOperator_ReturnsSingleError( ) { - RegisteredConnection conn = SeedServerConnection( ); - _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); - - List outputs = await ToListAsync( - _dispatcher.ExecuteCommandAsync( - conn.Id, - OperatorType.Action, - "noop", - TestContext.CancellationToken - ), - TestContext.CancellationToken - ); - - Assert.HasCount( - 1, - outputs - ); - Assert.AreEqual( - "Error", - outputs[0].LogLevel - ); - Assert.Contains( - "Unsupported operator type", - outputs[0].Message - ); - } - - /// - /// Verifies that returns a single error output when an unsupported is provided and no script arguments are supplied. - /// - [TestMethod] - public async Task ExecuteScriptAsync_UnsupportedOperatorWithoutArgs_ReturnsSingleError( ) { - RegisteredConnection conn = SeedServerConnection( ); - _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); - - List outputs = await ToListAsync( - _dispatcher.ExecuteScriptAsync( - conn.Id, - OperatorType.Action, - "script.ps1", - args: null, - TestContext.CancellationToken - ), - TestContext.CancellationToken - ); - - Assert.HasCount( - 1, - outputs - ); - Assert.AreEqual( - "Error", - outputs[0].LogLevel - ); - Assert.Contains( - "Unsupported operator type", - outputs[0].Message - ); - } - - /// - /// Verifies that returns a single error output when an unsupported is provided even with script arguments. - /// - [TestMethod] - public async Task ExecuteScriptAsync_UnsupportedOperatorWithArgs_ReturnsSingleError( ) { - RegisteredConnection conn = SeedServerConnection( ); - _ = await _dbContext.SaveChangesAsync( TestContext.CancellationToken ); - - List outputs = await ToListAsync( - _dispatcher.ExecuteScriptAsync( - conn.Id, - OperatorType.Action, - "script.ps1", - ["arg1", "arg2"], - TestContext.CancellationToken - ), - TestContext.CancellationToken - ); - - Assert.HasCount( - 1, - outputs - ); - Assert.AreEqual( - "Error", - outputs[0].LogLevel - ); - Assert.Contains( - "Unsupported operator type", - outputs[0].Message - ); - } - - /// - /// Creates and persists a with status - /// and generated RSA keys. - /// - private RegisteredConnection SeedServerConnection( ) { - RSAKeyPair keys = EncryptionProvider.GenerateRSAKeyPair( ); - - RegisteredConnection conn = new( ) { - ConnectionName = "DispatcherAgent", - RemoteUrl = "https://localhost:54321", - LocalPublicKey = keys.PublicKey, - LocalPrivateKey = keys.PrivateKey, - RemotePublicKey = keys.PublicKey, - OutboundApiKey = "outbound-key", - InboundApiKeyHash = "inbound-hash", - SharedKey = EncryptionProvider.GenerateRandomBytes( 32 ), - IsServer = true, - Status = ConnectionStatus.Connected, - }; - - _ = _dbContext.RegisteredConnections.Add( conn ); - return conn; - } - - /// - /// Collects all elements from an of into a list. - /// - private static async Task> ToListAsync( - IAsyncEnumerable sequence, - CancellationToken cancellationToken - ) { - - List outputs = []; - await foreach (OperatorOutput output in sequence.WithCancellation( cancellationToken )) { - outputs.Add( output ); - } - - return outputs; - } -} diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/NullEncryptionTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/NullEncryptionTests.cs index ffba83f..8e15a82 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Communication/NullEncryptionTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/NullEncryptionTests.cs @@ -73,21 +73,4 @@ public void DecryptFromEnvelope_KeyRotation_NullCurrentKey_ThrowsArgumentNullExc ) ); } - /// - /// Verifies that throws when a key is supplied. - /// - [TestMethod] - public void GrpcOutputReader_NullKey_ThrowsArgumentNullException( ) { - _ = Assert.ThrowsExactly( ( ) => { - // ReadAsync is an async iterator, so enumerate to trigger the guard - IAsyncEnumerable reader = GrpcOutputReader.ReadAsync( - null!, - null!, - TestContext.CancellationToken - ); - IAsyncEnumerator enumerator = reader.GetAsyncEnumerator( TestContext.CancellationToken ); - _ = enumerator.MoveNextAsync( ).AsTask( ).GetAwaiter( ).GetResult( ); - } ); - } } diff --git a/src/Test/Werkr.Tests.Data/Unit/Tasks/SuccessCriteriaEvaluatorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Tasks/SuccessCriteriaEvaluatorTests.cs index 0d2cd80..129b17c 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Tasks/SuccessCriteriaEvaluatorTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Tasks/SuccessCriteriaEvaluatorTests.cs @@ -213,8 +213,7 @@ public void ExitCodeCriteria_Null_Fails( ) { /// Verifies that "exitCode == 1" succeeds when exit code is 1. ///
[TestMethod] - public void ExitCodeCriteria_One_Succeeds() - { + public void ExitCodeCriteria_One_Succeeds( ) { bool result = _evaluator.Evaluate( TaskActionType.ShellCommand, "exitCode == 1", @@ -222,15 +221,14 @@ public void ExitCodeCriteria_One_Succeeds() output: [], exception: null ); - Assert.IsTrue(result); + Assert.IsTrue( result ); } /// /// Verifies that "exitCode == 1" fails when exit code is 0. /// [TestMethod] - public void ExitCodeCriteria_One_WhenZero_Fails() - { + public void ExitCodeCriteria_One_WhenZero_Fails( ) { bool result = _evaluator.Evaluate( TaskActionType.ShellCommand, "exitCode == 1", @@ -238,15 +236,14 @@ public void ExitCodeCriteria_One_WhenZero_Fails() output: [], exception: null ); - Assert.IsFalse(result); + Assert.IsFalse( result ); } /// /// Verifies that "exitCode == -1" succeeds when exit code is -1. /// [TestMethod] - public void ExitCodeCriteria_NegativeOne_Succeeds() - { + public void ExitCodeCriteria_NegativeOne_Succeeds( ) { bool result = _evaluator.Evaluate( TaskActionType.ShellCommand, "exitCode == -1", @@ -254,15 +251,14 @@ public void ExitCodeCriteria_NegativeOne_Succeeds() output: [], exception: null ); - Assert.IsTrue(result); + Assert.IsTrue( result ); } /// /// Verifies that "exitCode == 42" succeeds when exit code is 42. /// [TestMethod] - public void ExitCodeCriteria_FortyTwo_Succeeds() - { + public void ExitCodeCriteria_FortyTwo_Succeeds( ) { bool result = _evaluator.Evaluate( TaskActionType.ShellCommand, "exitCode == 42", @@ -270,15 +266,14 @@ public void ExitCodeCriteria_FortyTwo_Succeeds() output: [], exception: null ); - Assert.IsTrue(result); + Assert.IsTrue( result ); } /// /// Verifies that "exitCode == 42" fails when exit code is 0. /// [TestMethod] - public void ExitCodeCriteria_FortyTwo_WhenZero_Fails() - { + public void ExitCodeCriteria_FortyTwo_WhenZero_Fails( ) { bool result = _evaluator.Evaluate( TaskActionType.ShellCommand, "exitCode == 42", @@ -286,7 +281,7 @@ public void ExitCodeCriteria_FortyTwo_WhenZero_Fails() output: [], exception: null ); - Assert.IsFalse(result); + Assert.IsFalse( result ); } // ── Explicit Criteria: pwsh.HadErrors == false ── diff --git a/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowExecutorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowExecutorTests.cs deleted file mode 100644 index 0d17eae..0000000 --- a/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowExecutorTests.cs +++ /dev/null @@ -1,1581 +0,0 @@ -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using Werkr.Common.Configuration; -using Werkr.Common.Models; -using Werkr.Common.Models.Actions; -using Werkr.Core.Communication; -using Werkr.Core.Tasks; -using Werkr.Core.Workflows; -using Werkr.Data; -using Werkr.Data.Entities.Registration; -using Werkr.Data.Entities.Tasks; -using Werkr.Data.Entities.Workflows; - -namespace Werkr.Tests.Data.Unit.Workflows; - -/// -/// Unit tests for the class, validating workflow DAG traversal, conditional branching, -/// looping, multi-agent resolution, fan-in dependency modes, per-task success criteria, and run history retrieval. -/// -[TestClass] -public class WorkflowExecutorTests { - /// - /// The in-memory SQLite connection used for database operations. - /// - private SqliteConnection _connection = null!; - /// - /// The SQLite-backed used for test data persistence. - /// - private SqliteWerkrDbContext _dbContext = null!; - /// - /// The instance used to create and manage workflows and steps. - /// - private WorkflowService _workflowService = null!; - /// - /// The instance under test. - /// - private WorkflowExecutor _executor = null!; - /// - /// The configurable command dispatcher that simulates agent execution. - /// - private ConfigurableCommandDispatcher _dispatcher = null!; - - /// - /// Gets or sets the MSTest test context for the current test run. - /// - public TestContext TestContext { get; set; } = null!; - - /// - /// Initializes test infrastructure including an in-memory SQLite database, DI container, and all services required - /// by . - /// - [TestInitialize] - public void TestInit( ) { - _connection = new SqliteConnection( "DataSource=:memory:" ); - _connection.Open( ); - - DbContextOptions options = new DbContextOptionsBuilder( ) - .UseSqlite( _connection ) - .Options; - - _dbContext = new SqliteWerkrDbContext( options ); - _ = _dbContext.Database.EnsureCreated( ); - - _workflowService = new WorkflowService( - _dbContext, - NullLogger.Instance - ); - - ServiceCollection services = new( ); - _ = services.AddDbContext( - b => b.UseSqlite( _connection ), - ServiceLifetime.Scoped - ); - _ = services.AddDbContext( - b => b.UseSqlite( _connection ), - ServiceLifetime.Scoped - ); - ServiceProvider sp = services.BuildServiceProvider( ); - - AgentConnectionManager connectionManager = new( - sp.GetRequiredService( ), - NullLogger.Instance - ); - - AgentResolver agentResolver = new( - _dbContext, - connectionManager, - NullLogger.Instance - ); - ConditionEvaluator conditionEvaluator = new( NullLogger.Instance ); - - IOptions outputOptions = Options.Create( new JobOutputOptions { - OutputDirectory = Path.Combine( - Path.GetTempPath( ), - $"werkr_test_{Guid.NewGuid( ):N}" - ), - TailPreviewLength = 500, - } ); - JobOutputWriter outputWriter = new( - outputOptions, - NullLogger.Instance - ); - SuccessCriteriaEvaluator criteriaEvaluator = new( NullLogger.Instance ); - - _dispatcher = new ConfigurableCommandDispatcher( ); - JobExecutionService jobExecutionService = new( - _dbContext, - _dispatcher, - agentResolver, - outputWriter, - criteriaEvaluator, - NullLogger.Instance - ); - - _executor = new WorkflowExecutor( - _dbContext, - _workflowService, - jobExecutionService, - agentResolver, - conditionEvaluator, - new WorkflowRunTracker( ), - NullLogger.Instance - ); - } - - /// - /// Disposes the database context and SQLite connection after each test. - /// - [TestCleanup] - public void TestCleanup( ) { - _dbContext?.Dispose( ); - _connection?.Dispose( ); - } - - // ── Basic Execution ── - - /// - /// Verifies that executing a workflow with a single step completes successfully and sets an end time on the run. - /// - [TestMethod] - public async Task ExecuteAsync_SingleStep_CompletesSuccessfully( ) { - CancellationToken ct = TestContext.CancellationToken; - (Workflow workflow, _) = await SeedSingleStepWorkflowAsync( ct ); - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - Assert.IsNotNull( run.EndTime ); - } - - /// - /// Verifies that a linear two-step DAG executes both steps in dependency order and produces two jobs. - /// - [TestMethod] - public async Task ExecuteAsync_LinearDag_ExecutesInOrder( ) { - CancellationToken ct = TestContext.CancellationToken; - Workflow workflow = await SeedLinearWorkflowAsync( ct ); - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - } - - /// - /// Verifies that a diamond-shaped DAG with parallel branches completes successfully. - /// - [TestMethod] - public async Task ExecuteAsync_ParallelSteps_AllComplete( ) { - CancellationToken ct = TestContext.CancellationToken; - Workflow workflow = await SeedDiamondWorkflowAsync( ct ); - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - } - - // ── Control Flow ── - - /// - /// Verifies that an step whose condition evaluates to true executes the guarded branch. - /// - [TestMethod] - public async Task ExecuteAsync_IfConditionTrue_ExecutesBranch( ) { - CancellationToken ct = TestContext.CancellationToken; - Workflow workflow = await SeedIfWorkflowAsync( - "$? -eq $true", - ct - ); - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - } - - /// - /// Verifies that an step whose condition evaluates to false skips the guarded branch. - /// - [TestMethod] - public async Task ExecuteAsync_IfConditionFalse_SkipsBranch( ) { - CancellationToken ct = TestContext.CancellationToken; - // The stub dispatcher returns exit code 0 / success=true, - // so "$? -eq $false" will cause the If branch to be skipped - Workflow workflow = await SeedIfWorkflowAsync( - "$? -eq $false", - ct - ); - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - } - - // ── Workflow Runs ── - - /// - /// Verifies that returns the complete run history for a workflow. - /// - [TestMethod] - public async Task GetRunsAsync_ReturnsRunHistory( ) { - CancellationToken ct = TestContext.CancellationToken; - (Workflow workflow, _) = await SeedSingleStepWorkflowAsync( ct ); - - _ = await _executor.ExecuteAsync( - workflow, - ct - ); - _ = await _executor.ExecuteAsync( - workflow, - ct - ); - - IReadOnlyList runs = await _executor.GetRunsAsync( - workflow.Id, - 50, - ct - ); - - Assert.HasCount( - 2, - runs - ); - } - - /// - /// Verifies that returns a run with its associated jobs. - /// - [TestMethod] - public async Task GetRunAsync_ReturnsRunWithJobs( ) { - CancellationToken ct = TestContext.CancellationToken; - (Workflow workflow, _) = await SeedSingleStepWorkflowAsync( ct ); - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - WorkflowRun? loaded = await _executor.GetRunAsync( - run.Id, - ct - ); - - Assert.IsNotNull( loaded ); - Assert.AreEqual( - run.Id, - loaded.Id - ); - } - - /// - /// Verifies that returns for a non-existent run identifier. - /// - [TestMethod] - public async Task GetRunAsync_NotFound_ReturnsNull( ) { - CancellationToken ct = TestContext.CancellationToken; - WorkflowRun? result = await _executor.GetRunAsync( - Guid.NewGuid( ), - ct - ); - Assert.IsNull( result ); - } - - // ── Cancellation ── - - /// - /// Verifies that cancelling the token before execution sets the run status to . - /// - [TestMethod] - public async Task ExecuteAsync_Cancelled_SetsCancelledStatus( ) { - CancellationToken ct = TestContext.CancellationToken; - (Workflow workflow, _) = await SeedSingleStepWorkflowAsync( ct ); - - using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource( ct ); - await cts.CancelAsync( ); - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - cts.Token - ); - - Assert.AreEqual( - WorkflowRunStatus.Cancelled, - run.Status - ); - } - - // ── No Agent Available ── - - /// - /// Verifies that execution fails when no agent matches the task's target tags. - /// - [TestMethod] - public async Task ExecuteAsync_NoAgentAvailable_FailsRun( ) { - CancellationToken ct = TestContext.CancellationToken; - // Create workflow with step that has no matching agent (no agents registered) - Workflow workflow = new( ) { Name = "NoAgent", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - WerkrTask task = new( ) { - Name = "NoAgentTask", - Description = "Test", - ActionType = TaskActionType.ShellCommand, - Content = "echo hello", - TargetTags = ["nonexistent-agent-tag"], - }; - _ = _dbContext.Tasks.Add( task ); - _ = await _dbContext.SaveChangesAsync( ct ); - - _ = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - - // Reload to get steps - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Failed, - run.Status - ); - } - - // ── ElseIf Chain ── - - /// - /// Verifies that in an chain only the first branch whose condition is true is executed and - /// subsequent branches are skipped. - /// - [TestMethod] - public async Task ExecuteAsync_ElseIfChain_FirstTrueBranchTakenRestSkipped( ) { - CancellationToken ct = TestContext.CancellationToken; - _ = await SeedAgentAsync( ct ); - WerkrTask task = await SeedTaskAsync( ct ); - - Workflow workflow = new( ) { Name = "ElseIfChain", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - // Root → If ($? -eq $true) → ElseIf ($? -eq $true) - // If branch taken because root succeeds; ElseIf skipped because prior branch was taken. - WorkflowStep root = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - - WorkflowStep ifStep = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 1, ControlStatement = ControlStatement.If, ConditionExpression = "$? -eq $true", }, - ct - ); - await _workflowService.AddStepDependencyAsync( - ifStep.Id, - root.Id, - ct - ); - - WorkflowStep elseIfStep = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 2, ControlStatement = ControlStatement.ElseIf, ConditionExpression = "$? -eq $true", }, - ct - ); - await _workflowService.AddStepDependencyAsync( - elseIfStep.Id, - ifStep.Id, - ct - ); - - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - - // Verify: root + If branch executed (2 jobs), ElseIf skipped - WorkflowRun? loaded = await _executor.GetRunAsync( - run.Id, - ct - ); - Assert.IsNotNull( loaded ); - Assert.HasCount( - 2, - loaded.Jobs - ); - } - - // ── While Loop ── - - /// - /// Verifies that a loop respects the maximum iteration count and produces the expected number - /// of jobs. - /// - [TestMethod] - public async Task ExecuteAsync_WhileLoop_RespectsMaxIterations( ) { - CancellationToken ct = TestContext.CancellationToken; - _ = await SeedAgentAsync( ct ); - WerkrTask task = await SeedTaskAsync( ct ); - - Workflow workflow = new( ) { Name = "WhileLoop", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - WorkflowStep root = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - - // While condition is always true (exit code == 0), MaxIterations guards against infinite loop - WorkflowStep whileStep = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 1, ControlStatement = ControlStatement.While, ConditionExpression = "$exitCode == 0", MaxIterations = 3, }, - ct - ); - await _workflowService.AddStepDependencyAsync( - whileStep.Id, - root.Id, - ct - ); - - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - - // root + 3 while iterations = 4 jobs - WorkflowRun? loaded = await _executor.GetRunAsync( - run.Id, - ct - ); - Assert.IsNotNull( loaded ); - Assert.HasCount( - 4, - loaded.Jobs - ); - } - - // ── Do Loop ── - - /// - /// Verifies that a loop executes the step body at least once even when the condition is - /// immediately false. - /// - [TestMethod] - public async Task ExecuteAsync_DoLoop_ExecutesAtLeastOnce( ) { - CancellationToken ct = TestContext.CancellationToken; - _ = await SeedAgentAsync( ct ); - WerkrTask task = await SeedTaskAsync( ct ); - - Workflow workflow = new( ) { Name = "DoLoop", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - WorkflowStep root = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - - // Do: executes first, then checks condition. Condition false → stops after 1 iteration. - WorkflowStep doStep = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 1, ControlStatement = ControlStatement.Do, ConditionExpression = "$? -eq $false", MaxIterations = 10, }, - ct - ); - await _workflowService.AddStepDependencyAsync( - doStep.Id, - root.Id, - ct - ); - - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - - // root + 1 do iteration = 2 jobs (condition false after first execution) - WorkflowRun? loaded = await _executor.GetRunAsync( - run.Id, - ct - ); - Assert.IsNotNull( loaded ); - Assert.HasCount( - 2, - loaded.Jobs - ); - } - - // ── Dependency Failure ── - - /// - /// Verifies that a workflow fails when a step depends on a predecessor that was skipped due to a false condition. - /// - [TestMethod] - public async Task ExecuteAsync_DependencyNotSatisfied_FailsWorkflow( ) { - CancellationToken ct = TestContext.CancellationToken; - _ = await SeedAgentAsync( ct ); - WerkrTask task = await SeedTaskAsync( ct ); - - Workflow workflow = new( ) { Name = "DepFail", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - // Root → If ($? -eq $false, skipped) → Child (Sequential, depends on If) - // If is skipped so Child's dependency is not satisfied → workflow fails. - WorkflowStep root = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - - WorkflowStep ifStep = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 1, ControlStatement = ControlStatement.If, ConditionExpression = "$? -eq $false", }, - ct - ); - await _workflowService.AddStepDependencyAsync( - ifStep.Id, - root.Id, - ct - ); - - WorkflowStep child = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 2 }, - ct - ); - await _workflowService.AddStepDependencyAsync( - child.Id, - ifStep.Id, - ct - ); - - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Failed, - run.Status - ); - } - - // ── Empty Workflow ── - - /// - /// Verifies that a workflow with no steps completes immediately without producing any jobs. - /// - [TestMethod] - public async Task ExecuteAsync_NoSteps_CompletesImmediately( ) { - CancellationToken ct = TestContext.CancellationToken; - - Workflow workflow = new( ) { Name = "Empty", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - Assert.IsNotNull( run.EndTime ); - } - - // ── Multi-Agent Resolution ── - - /// - /// Verifies that steps with different target tags are dispatched to the correct agents based on tag resolution. - /// - [TestMethod] - public async Task ExecuteAsync_MultiAgent_StepsResolveToCorrectAgents( ) { - CancellationToken ct = TestContext.CancellationToken; - - RegisteredConnection agentA = await SeedAgentAsync( - "db-agent", - ["db-server"], - ct - ); - RegisteredConnection agentB = await SeedAgentAsync( - "app-agent", - ["app-server"], - ct - ); - - WerkrTask dbTask = await SeedTaskAsync( - "DbBackup", - ["db-server"], - null, - ct - ); - WerkrTask appTask = await SeedTaskAsync( - "AppDeploy", - ["app-server"], - null, - ct - ); - - Workflow workflow = new( ) { Name = "MultiAgent", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - WorkflowStep s1 = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = dbTask.Id, Order = 0 }, - ct - ); - WorkflowStep s2 = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = appTask.Id, Order = 1 }, - ct - ); - await _workflowService.AddStepDependencyAsync( - s2.Id, - s1.Id, - ct - ); - - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - Assert.HasCount( - 2, - _dispatcher.InvokedAgentIds - ); - Assert.AreEqual( - agentA.Id, - _dispatcher.InvokedAgentIds[0] - ); - Assert.AreEqual( - agentB.Id, - _dispatcher.InvokedAgentIds[1] - ); - } - - /// - /// Verifies that the workflow fails when a step's target tags cannot be resolved to any connected agent. - /// - [TestMethod] - public async Task ExecuteAsync_MultiAgent_UnresolvableTags_FailsWorkflow( ) { - CancellationToken ct = TestContext.CancellationToken; - - _ = await SeedAgentAsync( - "db-agent", - ["db-server"], - ct - ); - WerkrTask dbTask = await SeedTaskAsync( - "DbBackup", - ["db-server"], - null, - ct - ); - WerkrTask appTask = await SeedTaskAsync( - "AppDeploy", - ["nonexistent-tag"], - null, - ct - ); - - Workflow workflow = new( ) { Name = "UnresolvableTags", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - WorkflowStep s1 = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = dbTask.Id, Order = 0 }, - ct - ); - WorkflowStep s2 = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = appTask.Id, Order = 1 }, - ct - ); - await _workflowService.AddStepDependencyAsync( - s2.Id, - s1.Id, - ct - ); - - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Failed, - run.Status - ); - } - - // ── Agent Connection Override ── - - /// - /// Verifies that on a step bypasses tag-based agent resolution. - /// - [TestMethod] - public async Task ExecuteAsync_AgentOverride_BypassesTagResolution( ) { - CancellationToken ct = TestContext.CancellationToken; - - // Tag-matching agent (would be selected by normal tag resolution) - _ = await SeedAgentAsync( - "tag-agent", - ["test"], - ct - ); - // Override agent (different tags, wouldn't match task's TargetTags) - RegisteredConnection overrideAgent = await SeedAgentAsync( - "override-agent", - ["other"], - ct - ); - - WerkrTask task = await SeedTaskAsync( ct ); // TargetTags = ["test"] - - Workflow workflow = new( ) { Name = "Override", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - _ = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0, AgentConnectionIdOverride = overrideAgent.Id, }, - ct - ); - - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - Assert.HasCount( - 1, - _dispatcher.InvokedAgentIds - ); - Assert.AreEqual( - overrideAgent.Id, - _dispatcher.InvokedAgentIds[0] - ); - } - - /// - /// Verifies that a workflow with a mix of tag-resolved and override-resolved steps dispatches each step to the - /// correct agent. - /// - [TestMethod] - public async Task ExecuteAsync_MixedResolution_TagsAndOverride( ) { - CancellationToken ct = TestContext.CancellationToken; - - RegisteredConnection tagAgent = await SeedAgentAsync( - "tag-agent", - ["test"], - ct - ); - RegisteredConnection pinnedAgent = await SeedAgentAsync( - "pinned-agent", - ["pinned"], - ct - ); - - WerkrTask task = await SeedTaskAsync( ct ); // TargetTags = ["test"] - - Workflow workflow = new( ) { Name = "Mixed", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - // Step 1: tag resolution → tagAgent - WorkflowStep s1 = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - // Step 2: override → pinnedAgent - WorkflowStep s2 = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 1, AgentConnectionIdOverride = pinnedAgent.Id, }, - ct - ); - await _workflowService.AddStepDependencyAsync( - s2.Id, - s1.Id, - ct - ); - - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - Assert.HasCount( - 2, - _dispatcher.InvokedAgentIds - ); - Assert.AreEqual( - tagAgent.Id, - _dispatcher.InvokedAgentIds[0] - ); - Assert.AreEqual( - pinnedAgent.Id, - _dispatcher.InvokedAgentIds[1] - ); - } - - // ── Fan-In (DependencyMode) ── - - /// - /// Verifies that a fan-in step with waits for all predecessors to complete before - /// executing. - /// - [TestMethod] - public async Task ExecuteAsync_FanIn_DependencyModeAll_WaitsForAll( ) { - CancellationToken ct = TestContext.CancellationToken; - _ = await SeedAgentAsync( ct ); - WerkrTask task = await SeedTaskAsync( ct ); - - Workflow workflow = new( ) { Name = "FanInAll", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - // A and B are independent, C depends on both with DependencyMode.All - WorkflowStep a = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - WorkflowStep b = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 1 }, - ct - ); - WorkflowStep c = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 2, DependencyMode = DependencyMode.All, }, - ct - ); - await _workflowService.AddStepDependencyAsync( - c.Id, - a.Id, - ct - ); - await _workflowService.AddStepDependencyAsync( - c.Id, - b.Id, - ct - ); - - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - - // All 3 steps produced jobs - WorkflowRun? loaded = await _executor.GetRunAsync( - run.Id, - ct - ); - Assert.IsNotNull( loaded ); - Assert.HasCount( - 3, - loaded.Jobs - ); - } - - /// - /// Verifies that a fan-in step with proceeds once any single predecessor - /// completes, even when others are skipped. - /// - [TestMethod] - public async Task ExecuteAsync_FanIn_DependencyModeAny_ProceedsWithSingle( ) { - CancellationToken ct = TestContext.CancellationToken; - _ = await SeedAgentAsync( ct ); - WerkrTask task = await SeedTaskAsync( ct ); - - Workflow workflow = new( ) { Name = "FanInAny", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - // Root → A (If $? -eq $false → skipped), Root → B (Sequential → executes) - // C depends on both A and B with DependencyMode.Any → proceeds with B - WorkflowStep root = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - - WorkflowStep a = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 1, ControlStatement = ControlStatement.If, ConditionExpression = "$? -eq $false", }, - ct - ); - await _workflowService.AddStepDependencyAsync( - a.Id, - root.Id, - ct - ); - - WorkflowStep b = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 2 }, - ct - ); - await _workflowService.AddStepDependencyAsync( - b.Id, - root.Id, - ct - ); - - WorkflowStep c = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 3, DependencyMode = DependencyMode.Any, }, - ct - ); - await _workflowService.AddStepDependencyAsync( - c.Id, - a.Id, - ct - ); - await _workflowService.AddStepDependencyAsync( - c.Id, - b.Id, - ct - ); - - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - - // root + B + C = 3 jobs (A skipped) - WorkflowRun? loaded = await _executor.GetRunAsync( - run.Id, - ct - ); - Assert.IsNotNull( loaded ); - Assert.HasCount( - 3, - loaded.Jobs - ); - } - - /// - /// Verifies that a fan-in step with fails the workflow when one of its - /// predecessors is skipped. - /// - [TestMethod] - public async Task ExecuteAsync_FanIn_DependencyModeAll_SkippedPredecessor_FailsWorkflow( ) { - CancellationToken ct = TestContext.CancellationToken; - _ = await SeedAgentAsync( ct ); - WerkrTask task = await SeedTaskAsync( ct ); - - Workflow workflow = new( ) { Name = "FanInAllFail", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - // Same fan-in structure as Any test but C uses DependencyMode.All. - // A is skipped (no result), so C's All check fails → workflow fails. - WorkflowStep root = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - - WorkflowStep a = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 1, ControlStatement = ControlStatement.If, ConditionExpression = "$? -eq $false", }, - ct - ); - await _workflowService.AddStepDependencyAsync( - a.Id, - root.Id, - ct - ); - - WorkflowStep b = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 2 }, - ct - ); - await _workflowService.AddStepDependencyAsync( - b.Id, - root.Id, - ct - ); - - WorkflowStep c = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 3, DependencyMode = DependencyMode.All, }, - ct - ); - await _workflowService.AddStepDependencyAsync( - c.Id, - a.Id, - ct - ); - await _workflowService.AddStepDependencyAsync( - c.Id, - b.Id, - ct - ); - - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Failed, - run.Status - ); - } - - // ── Per-Task SuccessCriteria ── - - /// - /// Verifies that per-task success criteria (e.g., "always") are evaluated correctly so that a non-zero exit code - /// can still mark the job as successful. - /// - [TestMethod] - public async Task ExecuteAsync_PerTaskSuccessCriteria_CustomCriteriaEvaluated( ) { - CancellationToken ct = TestContext.CancellationToken; - _ = await SeedAgentAsync( ct ); - - // Task with SuccessCriteria = "always" — succeeds even with non-zero exit code - _dispatcher.DefaultExitCode = 1; - WerkrTask task = await SeedTaskAsync( - "AlwaysTask", - ["test"], - successCriteria: "always", - ct: ct - ); - - Workflow workflow = new( ) { Name = "Criteria", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - _ = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - - WorkflowRun run = await _executor.ExecuteAsync( - workflow, - ct - ); - - Assert.AreEqual( - WorkflowRunStatus.Completed, - run.Status - ); - - // Verify the job succeeded despite exit code 1 - WorkflowRun? loaded = await _executor.GetRunAsync( - run.Id, - ct - ); - Assert.IsNotNull( loaded ); - Assert.HasCount( - 1, - loaded.Jobs - ); - Assert.IsTrue( loaded.Jobs.First( ).Success ); - } - - // ── Helper Methods ── - - /// - /// Seeds a default agent with the "test" tag into the database. - /// - private Task SeedAgentAsync( CancellationToken ct ) => - SeedAgentAsync( - "test-agent", - ["test"], - ct - ); - - private async Task SeedAgentAsync( - string name, - string[] tags, - CancellationToken ct - ) { - RegisteredConnection agent = new( ) { - Id = Guid.NewGuid( ), - ConnectionName = name, - RemoteUrl = "https://localhost:5001", - Tags = tags, - Status = ConnectionStatus.Connected, - SharedKey = new byte[32], - IsServer = true, - }; - _ = _dbContext.RegisteredConnections.Add( agent ); - _ = await _dbContext.SaveChangesAsync( ct ); - return agent; - } - - /// - /// Seeds a default task with the "test" target tag into the database. - /// - private Task SeedTaskAsync( CancellationToken ct ) => - SeedTaskAsync( - "TestTask", - ["test"], - null, - ct - ); - - private async Task SeedTaskAsync( - string name, - string[] targetTags, - string? successCriteria, - CancellationToken ct - ) { - WerkrTask task = new( ) { - Name = name, - Description = "Test", - ActionType = TaskActionType.ShellCommand, - Content = "echo hello", - TargetTags = targetTags, - SuccessCriteria = successCriteria, - }; - _ = _dbContext.Tasks.Add( task ); - _ = await _dbContext.SaveChangesAsync( ct ); - return task; - } - - /// - /// Seeds a workflow with a single step, including an agent and a task. - /// - private async Task<(Workflow Workflow, WorkflowStep Step)> SeedSingleStepWorkflowAsync( CancellationToken ct ) { - _ = await SeedAgentAsync( ct ); - WerkrTask task = await SeedTaskAsync( ct ); - - Workflow workflow = new( ) { Name = "Single", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - WorkflowStep step = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - - // Reload with steps - workflow = (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - return ( - workflow, - step - ); - } - - /// - /// Seeds a workflow with two steps linked in a linear dependency chain. - /// - private async Task SeedLinearWorkflowAsync( CancellationToken ct ) { - _ = await SeedAgentAsync( ct ); - WerkrTask task = await SeedTaskAsync( ct ); - - Workflow workflow = new( ) { Name = "Linear", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - WorkflowStep s1 = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - WorkflowStep s2 = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 1 }, - ct - ); - await _workflowService.AddStepDependencyAsync( - s2.Id, - s1.Id, - ct - ); - - return (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - } - - /// - /// Seeds a diamond-shaped workflow with four steps: one root, two parallel branches, and one convergent step. - /// - private async Task SeedDiamondWorkflowAsync( CancellationToken ct ) { - _ = await SeedAgentAsync( ct ); - WerkrTask task = await SeedTaskAsync( ct ); - - Workflow workflow = new( ) { Name = "Diamond", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - // A → B, A → C, B → D, C → D (diamond shape) - WorkflowStep a = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - WorkflowStep b = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 1 }, - ct - ); - WorkflowStep c = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 2 }, - ct - ); - WorkflowStep d = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 3 }, - ct - ); - await _workflowService.AddStepDependencyAsync( - b.Id, - a.Id, - ct - ); - await _workflowService.AddStepDependencyAsync( - c.Id, - a.Id, - ct - ); - await _workflowService.AddStepDependencyAsync( - d.Id, - b.Id, - ct - ); - await _workflowService.AddStepDependencyAsync( - d.Id, - c.Id, - ct - ); - - return (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - } - - /// - /// Seeds a workflow with a root step followed by a conditional step using the supplied condition - /// expression. - /// - private async Task SeedIfWorkflowAsync( - string condition, - CancellationToken ct - ) { - _ = await SeedAgentAsync( ct ); - WerkrTask task = await SeedTaskAsync( ct ); - - Workflow workflow = new( ) { Name = "IfBranch", Description = string.Empty }; - _ = await _workflowService.CreateAsync( - workflow, - ct - ); - - // Step 1: Sequential (root) - WorkflowStep root = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 0 }, - ct - ); - - // Step 2: If branch (depends on root) - WorkflowStep ifStep = await _workflowService.AddStepAsync( - workflow.Id, - new WorkflowStep { TaskId = task.Id, Order = 1, ControlStatement = ControlStatement.If, ConditionExpression = condition, }, - ct - ); - await _workflowService.AddStepDependencyAsync( - ifStep.Id, - root.Id, - ct - ); - - return (await _workflowService.GetByIdAsync( - workflow.Id, - ct - ))!; - } - - /// - /// Configurable command dispatcher for testing. Returns output with a configurable - /// exit code and tracks which agents received invocations. - /// - private sealed class ConfigurableCommandDispatcher : ICommandDispatcher { - /// Default exit code returned for all invocations unless overridden per-agent. - public int DefaultExitCode { get; set; } - - /// Per-agent exit code overrides. - public Dictionary AgentExitCodes { get; } = []; - - /// Tracks which agent IDs received invocations, in order. - public List InvokedAgentIds { get; } = []; - - /// - /// Simulates executing a shell command on the specified agent and yields synthetic output including a - /// configurable exit code. - /// - public async IAsyncEnumerable ExecuteCommandAsync( - Guid agentConnectionId, - OperatorType operatorType, - string command, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default - ) { - InvokedAgentIds.Add( agentConnectionId ); - int exitCode = AgentExitCodes.GetValueOrDefault( - agentConnectionId, - DefaultExitCode - ); - await Task.CompletedTask; - yield return OperatorOutput.Create( - "Information", - "OK" - ); - yield return OperatorOutput.Create( - "Information", - $"Process exited with code {exitCode}" - ); - } - - /// - /// Simulates executing a script on the specified agent and yields synthetic output including a configurable - /// exit code. - /// - public async IAsyncEnumerable ExecuteScriptAsync( - Guid agentConnectionId, - OperatorType operatorType, - string scriptPath, - IEnumerable? args, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default - ) { - InvokedAgentIds.Add( agentConnectionId ); - int exitCode = AgentExitCodes.GetValueOrDefault( - agentConnectionId, - DefaultExitCode - ); - await Task.CompletedTask; - yield return OperatorOutput.Create( - "Information", - "OK" - ); - yield return OperatorOutput.Create( - "Information", - $"Process exited with code {exitCode}" - ); - } - - /// - /// Simulates executing an action descriptor on the specified agent and yields synthetic output including a - /// configurable exit code. - /// - public async IAsyncEnumerable ExecuteActionAsync( - Guid agentConnectionId, - ActionDescriptor descriptor, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default - ) { - InvokedAgentIds.Add( agentConnectionId ); - int exitCode = AgentExitCodes.GetValueOrDefault( - agentConnectionId, - DefaultExitCode - ); - await Task.CompletedTask; - yield return OperatorOutput.Create( - "Information", - "OK" - ); - yield return OperatorOutput.Create( - "Information", - $"Process exited with code {exitCode}" - ); - } - } -} diff --git a/src/Werkr.Agent/Communication/AgentGrpcClientFactory.cs b/src/Werkr.Agent/Communication/AgentGrpcClientFactory.cs index dd50902..6431cfd 100644 --- a/src/Werkr.Agent/Communication/AgentGrpcClientFactory.cs +++ b/src/Werkr.Agent/Communication/AgentGrpcClientFactory.cs @@ -67,15 +67,14 @@ ILogger logger } /// - /// Creates a for requesting workflow execution on the Server. + /// Creates an for + /// pushing real-time output to the Server. /// /// Cancellation token. /// A configured gRPC client. - public async Task CreateWorkflowExecutionClientAsync( - CancellationToken ct = default - ) { + public async Task CreateOutputStreamingClientAsync( CancellationToken ct = default ) { await EnsureInitializedAsync( ct ); - return new WorkflowExecution.WorkflowExecutionClient( _channel ); + return new OutputStreamingService.OutputStreamingServiceClient( _channel ); } /// diff --git a/src/Werkr.Agent/Program.cs b/src/Werkr.Agent/Program.cs index 96397b6..538fea5 100644 --- a/src/Werkr.Agent/Program.cs +++ b/src/Werkr.Agent/Program.cs @@ -124,6 +124,11 @@ public static async Task Main( string[] args ) { builder.Configuration.GetSection( JobOutputOptions.SectionName ) ); _ = builder.Services.AddSingleton( ); _ = builder.Services.AddSingleton( ); + _ = builder.Services.AddSingleton( sp => + new Werkr.Core.Workflows.ConditionEvaluator( + sp.GetRequiredService( ).CreateLogger( ) ) ); + _ = builder.Services.AddSingleton( ); + _ = builder.Services.AddSingleton( ); _ = builder.Services.AddSingleton( Channel.CreateUnbounded( new UnboundedChannelOptions { SingleReader = true } ) ); _ = builder.Services.AddHostedService( ); @@ -151,9 +156,6 @@ public static async Task Main( string[] args ) { } // Map gRPC services - _ = app.MapGrpcService( ); - _ = app.MapGrpcService( ); - _ = app.MapGrpcService( ); _ = app.MapGrpcService( ); _ = app.MapGrpcService( ); _ = app.MapGrpcService( ); @@ -166,6 +168,10 @@ public static async Task Main( string[] args ) { _ = app.MapGet( "/", GetAgentArt ); + // Start the output streaming service (opens persistent gRPC stream to server) + OutputStreamingService outputStreaming = app.Services.GetRequiredService( ); + outputStreaming.Start( app.Lifetime.ApplicationStopping ); + await app.RunAsync( ); } catch (Exception ex) { Log.Fatal( ex, "Werkr Agent terminated unexpectedly." ); diff --git a/src/Werkr.Agent/Protos/Action.proto b/src/Werkr.Agent/Protos/Action.proto deleted file mode 100644 index 3351ca6..0000000 --- a/src/Werkr.Agent/Protos/Action.proto +++ /dev/null @@ -1,20 +0,0 @@ -syntax = "proto3"; - -option csharp_namespace = "Werkr.Agent.Protos"; - -import "EncryptedEnvelope.proto"; - -// Built-in action service. -// All RPCs use EncryptedEnvelope for request/response payloads. -// Request envelope contains a serialized ActionRequest. -// Streaming response envelopes each contain a serialized GrpcLogMsg. -service Action { - rpc RunAction (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); -} - -// Inner payload message — serialized, encrypted, then placed inside EncryptedEnvelope. -// NOT used directly in the RPC signature. -message ActionRequest { - string action_name = 1; - string parameters_json = 2; -} diff --git a/src/Werkr.Agent/Protos/Shell.proto b/src/Werkr.Agent/Protos/Shell.proto deleted file mode 100644 index bb832a4..0000000 --- a/src/Werkr.Agent/Protos/Shell.proto +++ /dev/null @@ -1,34 +0,0 @@ -syntax = "proto3"; - -option csharp_namespace = "Werkr.Agent.Protos"; - -import "EncryptedEnvelope.proto"; - -// PowerShell shell service. -// All RPCs use EncryptedEnvelope for request/response payloads. -// Request envelopes contain a serialized ShellRequest or ScriptRequest. -// Streaming response envelopes each contain a serialized GrpcLogMsg. -service Pwsh { - rpc RunCommand (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); - rpc RunScript (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); - rpc RunScriptWithArgs (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); -} - -// System shell service (cmd.exe / bash). -// All RPCs use EncryptedEnvelope for request/response payloads. -service SystemShell { - rpc RunCommand (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); - rpc RunScript (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); - rpc RunScriptWithArgs (werkr.common.protos.EncryptedEnvelope) returns (stream werkr.common.protos.EncryptedEnvelope); -} - -// Inner payload messages — serialized, encrypted, then placed inside EncryptedEnvelope. -// These are NOT used directly in RPC signatures. -message ShellRequest { - string command = 1; -} - -message ScriptRequest { - string script = 1; - repeated string args = 2; -} diff --git a/src/Werkr.Agent/Scheduling/ScheduleEvaluatorService.cs b/src/Werkr.Agent/Scheduling/ScheduleEvaluatorService.cs index e43a829..cd7ea75 100644 --- a/src/Werkr.Agent/Scheduling/ScheduleEvaluatorService.cs +++ b/src/Werkr.Agent/Scheduling/ScheduleEvaluatorService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Threading.Channels; +using Microsoft.EntityFrameworkCore; using Werkr.Agent.Communication; using Werkr.Agent.Operators; using Werkr.Common.Models.Actions; @@ -8,6 +9,7 @@ using Werkr.Core.Operators; using Werkr.Core.Scheduling; using Werkr.Core.Tasks; +using Werkr.Data; using Werkr.Data.Calendar.Enums; using Werkr.Data.Calendar.Models; using Werkr.Data.Entities.Schedule; @@ -37,7 +39,10 @@ namespace Werkr.Agent.Scheduling; /// PowerShell operator. /// System shell operator. /// Built-in action operator. +/// Service for executing workflows locally on the agent. +/// Manages real-time output streaming to the server. /// Channel for receiving invalidation signals. +/// Factory for creating DI scopes to resolve scoped services (e.g. WerkrDbContext). /// Logger. public sealed class ScheduleEvaluatorService( AgentGrpcClientFactory clientFactory, @@ -46,7 +51,10 @@ public sealed class ScheduleEvaluatorService( PwshOperator pwshOperator, SystemShellOperator shellOperator, IActionOperator actionOperator, + WorkflowExecutionService workflowExecutionService, + Werkr.Agent.Services.OutputStreamingService outputStreamingService, Channel invalidationChannel, + IServiceScopeFactory serviceScopeFactory, ILogger logger ) : BackgroundService { @@ -215,8 +223,8 @@ internal async Task SyncSchedulesAsync( CancellationToken ct ) { // ── Bulk-fetch holiday dates for holiday-enabled schedules ── await FetchBulkHolidayDatesAsync( response, ct ); - // Rebuild fire queue - RebuildFireQueue( ); + // Rebuild fire queue (includes catch-up for missed occurrences) + await RebuildFireQueueAsync( ); // Update sync times DateTime now = DateTime.UtcNow; @@ -299,56 +307,206 @@ private async Task FetchBulkHolidayDatesAsync( AgentScheduleResponse response, C /// /// Rebuilds the fire queue from current definitions. /// Calculates next occurrence for each task/workflow using . + /// When a schedule has set, any past occurrences + /// that have no corresponding in the local database are enqueued + /// immediately so the agent catches up on missed executions. /// - internal void RebuildFireQueue( ) { - lock (_queueLock) { - _fireQueue.Clear( ); + internal async Task RebuildFireQueueAsync( ) { + DateTime now = DateTime.UtcNow; + DateTime endOfWindow = now.AddHours( 24 ); // Look ahead 24 hours - DateTime now = DateTime.UtcNow; - DateTime endOfWindow = now.AddHours( 24 ); // Look ahead 24 hours + // 1. Snapshot definitions outside the queue lock so we can await DB calls. + List tasks; + List workflows; + lock (_definitionsLock) { + tasks = [.. _currentTasks]; + workflows = [.. _currentWorkflows]; + } - lock (_definitionsLock) { - foreach (ScheduledTaskDefinition task in _currentTasks) { - if (task.Schedule is null) { - continue; - } + // 2. Identify which schedules need catch-up and pre-fetch executed times. + HashSet catchUpScheduleIds = []; + foreach (ScheduledTaskDefinition task in tasks) { + if (task.Schedule is null) { + continue; + } + Schedule schedule = MapProtoToSchedule( task.Schedule ); + if (schedule.DbSchedule.CatchUpEnabled && schedule.DbSchedule.Id != Guid.Empty) { + _ = catchUpScheduleIds.Add( schedule.DbSchedule.Id ); + } + } + foreach (ScheduledWorkflowDefinition workflow in workflows) { + if (workflow.Schedule is null) { + continue; + } + Schedule schedule = MapProtoToSchedule( workflow.Schedule ); + if (schedule.DbSchedule.CatchUpEnabled && schedule.DbSchedule.Id != Guid.Empty) { + _ = catchUpScheduleIds.Add( schedule.DbSchedule.Id ); + } + } - try { - Schedule schedule = MapProtoToSchedule( task.Schedule ); - DateTime? next = CalculateNextWithHolidays( schedule, now, endOfWindow ); - if (next.HasValue && next.Value != default) { - _ = _fireQueue.Add( new FireQueueEntry( next.Value, task, null ) ); - } - } catch (Exception ex) { - logger.LogWarning( ex, "Failed to calculate occurrences for task {TaskId} '{TaskName}'.", - task.TaskId, task.Name ); - } + Dictionary> executedTicksBySchedule = []; + if (catchUpScheduleIds.Count > 0) { + executedTicksBySchedule = await GetExecutedOccurrenceTicksAsync( catchUpScheduleIds ); + } + + // 3. Build new fire queue entries. + SortedSet newEntries = []; + + foreach (ScheduledTaskDefinition task in tasks) { + if (task.Schedule is null) { + continue; + } + + try { + Schedule schedule = MapProtoToSchedule( task.Schedule ); + + // Normal forward-looking entry + DateTime? next = CalculateNextWithHolidays( schedule, now, endOfWindow ); + if (next.HasValue && next.Value != default) { + _ = newEntries.Add( new FireQueueEntry( next.Value, task, null ) ); } - foreach (ScheduledWorkflowDefinition workflow in _currentWorkflows) { - if (workflow.Schedule is null) { - continue; + // Catch-up: enqueue missed past occurrences + if (schedule.DbSchedule.CatchUpEnabled && schedule.DbSchedule.Id != Guid.Empty) { + _ = executedTicksBySchedule.TryGetValue( schedule.DbSchedule.Id, out HashSet? executedTicks ); + IReadOnlyList missed = GetMissedOccurrences( schedule, now, executedTicks ); + foreach (DateTime missedTime in missed) { + _ = newEntries.Add( new FireQueueEntry( missedTime, task, null ) ); } + if (missed.Count > 0 && logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Catch-up: enqueued {Count} missed occurrence(s) for task {TaskId} '{TaskName}'.", + missed.Count, task.TaskId, task.Name ); + } + } + } catch (Exception ex) { + logger.LogWarning( ex, "Failed to calculate occurrences for task {TaskId} '{TaskName}'.", + task.TaskId, task.Name ); + } + } + + foreach (ScheduledWorkflowDefinition workflow in workflows) { + if (workflow.Schedule is null) { + continue; + } - try { - Schedule schedule = MapProtoToSchedule( workflow.Schedule ); - DateTime? next = CalculateNextWithHolidays( schedule, now, endOfWindow ); - if (next.HasValue && next.Value != default) { - _ = _fireQueue.Add( new FireQueueEntry( next.Value, null, workflow ) ); - } - } catch (Exception ex) { - logger.LogWarning( ex, "Failed to calculate occurrences for workflow {WorkflowId} '{WorkflowName}'.", - workflow.WorkflowId, workflow.Name ); + try { + Schedule schedule = MapProtoToSchedule( workflow.Schedule ); + + // Normal forward-looking entry + DateTime? next = CalculateNextWithHolidays( schedule, now, endOfWindow ); + if (next.HasValue && next.Value != default) { + _ = newEntries.Add( new FireQueueEntry( next.Value, null, workflow ) ); + } + + // Catch-up: enqueue missed past occurrences + if (schedule.DbSchedule.CatchUpEnabled && schedule.DbSchedule.Id != Guid.Empty) { + _ = executedTicksBySchedule.TryGetValue( schedule.DbSchedule.Id, out HashSet? executedTicks ); + IReadOnlyList missed = GetMissedOccurrences( schedule, now, executedTicks ); + foreach (DateTime missedTime in missed) { + _ = newEntries.Add( new FireQueueEntry( missedTime, null, workflow ) ); + } + if (missed.Count > 0 && logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Catch-up: enqueued {Count} missed occurrence(s) for workflow {WorkflowId} '{WorkflowName}'.", + missed.Count, workflow.WorkflowId, workflow.Name ); } } + } catch (Exception ex) { + logger.LogWarning( ex, "Failed to calculate occurrences for workflow {WorkflowId} '{WorkflowName}'.", + workflow.WorkflowId, workflow.Name ); + } + } + + // 4. Swap under lock + lock (_queueLock) { + _fireQueue.Clear( ); + foreach (FireQueueEntry entry in newEntries) { + _ = _fireQueue.Add( entry ); } + } - if (logger.IsEnabled( LogLevel.Debug )) { - logger.LogDebug( "Fire queue rebuilt: {Count} entries. Next fire: {NextFire}.", - _fireQueue.Count, - _fireQueue.Count > 0 ? _fireQueue.Min!.FireTimeUtc.ToString( "o" ) : "none" ); + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Fire queue rebuilt: {Count} entries. Next fire: {NextFire}.", + newEntries.Count, + newEntries.Count > 0 ? newEntries.Min!.FireTimeUtc.ToString( "o" ) : "none" ); + } + } + + /// + /// Queries the local database for job start times associated with the given schedule IDs. + /// Returns a dictionary keyed by schedule ID whose values are sets of + /// (truncated to the minute) so callers can quickly check + /// whether a computed occurrence was already executed. + /// + private async Task>> GetExecutedOccurrenceTicksAsync( + HashSet scheduleIds + ) { + using IServiceScope scope = serviceScopeFactory.CreateScope( ); + WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); + + List jobs = await db.Jobs + .AsNoTracking( ) + .Where( j => j.ScheduleId.HasValue && scheduleIds.Contains( j.ScheduleId.Value ) ) + .ToListAsync( ); + + Dictionary> result = []; + foreach (WerkrJob job in jobs) { + Guid sid = job.ScheduleId!.Value; + if (!result.TryGetValue( sid, out HashSet? ticks )) { + ticks = []; + result[sid] = ticks; } + // Truncate to minute precision so small timing differences don't cause duplicates. + DateTime truncated = new( + job.StartTime.Year, + job.StartTime.Month, + job.StartTime.Day, + job.StartTime.Hour, + job.StartTime.Minute, + 0, DateTimeKind.Utc ); + _ = ticks.Add( truncated.Ticks ); } + + return result; + } + + /// + /// Returns past occurrences of that have not been executed. + /// Compares all occurrences from the schedule start through against + /// the set of already-executed ticks. Any occurrence absent from the set is considered missed. + /// + private IReadOnlyList GetMissedOccurrences( + Schedule schedule, + DateTime now, + HashSet? executedTicks + ) { + Guid scheduleId = schedule.DbSchedule.Id; + IReadOnlyList allPast; + + if (_holidayCache.TryGetValue( scheduleId, out (HolidayCalendarMode Mode, IReadOnlyList Dates) cached )) { + ScheduleOccurrenceResult result = ScheduleCalculator.CalculateOccurrences( + schedule, now, cached.Dates, cached.Mode ); + allPast = [.. result.Occurrences.Where( o => o <= now )]; + } else { + allPast = [.. ScheduleCalculator.CalculateOccurrences( schedule, now ).Where( o => o <= now )]; + } + + if (allPast.Count == 0 || executedTicks is null) { + return allPast; + } + + List missed = []; + foreach (DateTime occurrence in allPast) { + DateTime truncated = new( + occurrence.Year, occurrence.Month, occurrence.Day, + occurrence.Hour, occurrence.Minute, 0, DateTimeKind.Utc ); + if (!executedTicks.Contains( truncated.Ticks )) { + missed.Add( occurrence ); + } + } + + return missed; } // ── Evaluation Loop ────────────────────────────────────────────────────────── @@ -415,7 +573,7 @@ private async Task WaitForInvalidationAsync( CancellationToken ct ) { logger.LogDebug( "Invalidation signal received. Will re-sync." ); } await SyncWithBackoffAsync( ct ); - RebuildFireQueue( ); + await RebuildFireQueueAsync( ); } catch (OperationCanceledException) { // Expected — the linked token was cancelled } @@ -435,7 +593,7 @@ private async Task DrainInvalidationChannelAsync( CancellationToken ct ) { if (hadInvalidations) { await SyncWithBackoffAsync( ct ); - RebuildFireQueue( ); + await RebuildFireQueueAsync( ); } } @@ -463,7 +621,7 @@ private async Task CheckPerTaskReSyncAsync( CancellationToken ct ) { logger.LogDebug( "Periodic re-sync interval reached. Re-syncing schedules." ); } await SyncWithBackoffAsync( ct ); - RebuildFireQueue( ); + await RebuildFireQueueAsync( ); } } @@ -491,7 +649,7 @@ private async Task FireDueEntriesAsync( CancellationToken ct ) { if (entry.Task is not null) { await ExecuteTaskLocallyAsync( entry.Task, ct ); } else if (entry.Workflow is not null) { - await DelegateWorkflowAsync( entry.Workflow, ct ); + await ExecuteWorkflowLocallyAsync( entry.Workflow, ct ); } } catch (Exception ex) { string itemName = entry.Task?.Name ?? entry.Workflow?.Name ?? "unknown"; @@ -620,10 +778,24 @@ internal async Task ExecuteTaskLocallyAsync( ScheduledTaskDefinition taskDef, Ca OperatorExecution execution = RunOperator( taskDef, actionType, timeoutCts.Token ); - // Stream output to disk + // Resolve schedule ID for output streaming + string scheduleIdStr = taskDef.Schedule?.ScheduleId ?? ""; + + // Stream output to disk and output streaming service await foreach (OperatorOutput output in execution.Output.WithCancellation( timeoutCts.Token )) { await outputWriter.WriteLineAsync( jobId, output, timeoutCts.Token ); collectedOutput.Add( output ); + + outputStreamingService.Publish( new OutputMessage { + TaskId = taskDef.TaskId, + ScheduleId = scheduleIdStr, + JobId = jobId.ToString( ), + Line = new OutputLine { + Text = output.Message, + LogLevel = output.LogLevel, + Timestamp = output.Timestamp, + }, + } ); } // Await the typed result @@ -670,8 +842,31 @@ internal async Task ExecuteTaskLocallyAsync( ScheduledTaskDefinition taskDef, Ca // Build tail preview for the server (matches ad-hoc job behavior) string? tailPreview = await outputWriter.GetTailPreviewAsync( jobId, ct ); - // Report result to server - await ReportJobResultAsync( jobId, taskDef, startTime, endTime, success, exitCode, errorCategory, null, tailPreview, ct ); + // Resolve schedule ID from the task definition + Guid? scheduleId = taskDef.Schedule is not null + && Guid.TryParse( taskDef.Schedule.ScheduleId, out Guid sid ) + ? sid : null; + + // Persist job locally in the agent's SQLite database + await PersistJobLocallyAsync( jobId, taskDef.TaskId, taskDef.Content, startTime, endTime, + success, exitCode, errorCategory, tailPreview, null, scheduleId, ct ); + + // Report result to server (includes agent-assigned job ID and schedule ID for upsert) + await ReportJobResultAsync( jobId, taskDef, startTime, endTime, success, exitCode, errorCategory, null, tailPreview, scheduleId, ct ); + + // Publish completion to output streaming service and clean up buffer + string completeScheduleIdStr = taskDef.Schedule?.ScheduleId ?? ""; + outputStreamingService.Publish( new OutputMessage { + TaskId = taskDef.TaskId, + ScheduleId = completeScheduleIdStr, + JobId = jobId.ToString( ), + Complete = new OutputComplete { + ExitCode = exitCode, + Success = success, + ErrorMessage = executionException?.Message ?? "", + }, + } ); + outputStreamingService.ClearBuffer( taskDef.TaskId, completeScheduleIdStr ); if (logger.IsEnabled( LogLevel.Information )) { logger.LogInformation( "Job {JobId} for task '{TaskName}' completed: success={Success}, exitCode={ExitCode}, duration={Duration}.", @@ -713,47 +908,23 @@ private OperatorExecution RunOperator( ScheduledTaskDefinition taskDef, TaskActi }; } - // ── Workflow Delegation ────────────────────────────────────────────────────── + // ── Workflow Execution ───────────────────────────────────────────────────── /// - /// Delegates workflow execution to the Server. Only the Server can orchestrate - /// multi-agent workflows because it has visibility across all agents and connectivity. + /// Executes a workflow locally on the agent using . /// - private async Task DelegateWorkflowAsync( ScheduledWorkflowDefinition workflow, CancellationToken ct ) { + private async Task ExecuteWorkflowLocallyAsync( ScheduledWorkflowDefinition workflow, CancellationToken ct ) { if (logger.IsEnabled( LogLevel.Information )) { - logger.LogInformation( "Delegating workflow {WorkflowId} '{WorkflowName}' to server.", + logger.LogInformation( "Executing workflow {WorkflowId} '{WorkflowName}' locally.", workflow.WorkflowId, workflow.Name ); } try { - WorkflowExecution.WorkflowExecutionClient client = - await clientFactory.CreateWorkflowExecutionClientAsync( ct ); - Grpc.Core.CallOptions callOptions = clientFactory.CreateCallOptions( cancellationToken: ct ); - - RegisteredConnectionInfo connection = await GetConnectionInfoAsync( ct ); - - WorkflowRunGrpcRequest innerRequest = new( ) { - WorkflowId = workflow.WorkflowId, - ConnectionId = connection.ConnectionId, - }; - EncryptedEnvelope requestEnvelope = PayloadEncryptor.EncryptToEnvelope( - innerRequest, clientFactory.GetSharedKey( ), clientFactory.GetKeyId( ) ); - EncryptedEnvelope responseEnvelope = await client.RequestWorkflowRunAsync( - requestEnvelope, callOptions ); - WorkflowRunGrpcResponse response = PayloadEncryptor.DecryptFromEnvelope( - responseEnvelope, clientFactory.GetSharedKey( ) ); - - if (response.Accepted) { - if (logger.IsEnabled( LogLevel.Information )) { - logger.LogInformation( "Workflow {WorkflowId} accepted. RunId={RunId}.", - workflow.WorkflowId, response.WorkflowRunId ); - } - } else { - logger.LogWarning( "Workflow {WorkflowId} rejected by server: {Message}.", - workflow.WorkflowId, response.Message ); - } + await workflowExecutionService.ExecuteWorkflowLocallyAsync( workflow, ct ); + } catch (OperationCanceledException) when (ct.IsCancellationRequested) { + throw; // Propagate shutdown } catch (Exception ex) { - logger.LogError( ex, "Failed to delegate workflow {WorkflowId} '{WorkflowName}' to server.", + logger.LogError( ex, "Failed to execute workflow {WorkflowId} '{WorkflowName}' locally.", workflow.WorkflowId, workflow.Name ); } } @@ -779,6 +950,7 @@ private async Task ReportJobResultAsync( ErrorCategory errorCategory, string? workflowRunId, string? outputPreview, + Guid? scheduleId, CancellationToken ct ) { @@ -796,6 +968,7 @@ CancellationToken ct ExitCode = exitCode, ErrorCategory = (int) errorCategory, OutputPath = AgentJobOutputWriter.GetRelativeOutputPath( jobId ), + JobId = jobId.ToString( ), }; if (!string.IsNullOrWhiteSpace( workflowRunId )) { innerRequest.WorkflowRunId = workflowRunId; @@ -803,6 +976,9 @@ CancellationToken ct if (!string.IsNullOrWhiteSpace( outputPreview )) { innerRequest.OutputPreview = outputPreview; } + if (scheduleId.HasValue) { + innerRequest.ScheduleId = scheduleId.Value.ToString( ); + } EncryptedEnvelope requestEnvelope = PayloadEncryptor.EncryptToEnvelope( innerRequest, clientFactory.GetSharedKey( ), clientFactory.GetKeyId( ) ); @@ -836,6 +1012,63 @@ CancellationToken ct } } + // ── Local Job Persistence ──────────────────────────────────────────────────── + + /// + /// Persists a completed job to the agent's local SQLite database. + /// Creates a DI scope to resolve a scoped . + /// + private async Task PersistJobLocallyAsync( + Guid jobId, + long taskId, + string taskSnapshot, + DateTime startTime, + DateTime endTime, + bool success, + int exitCode, + ErrorCategory errorCategory, + string? outputPreview, + string? workflowRunId, + Guid? scheduleId, + CancellationToken ct + ) { + try { + await using AsyncServiceScope scope = serviceScopeFactory.CreateAsyncScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + RegisteredConnectionInfo connection = await GetConnectionInfoAsync( ct ); + + WerkrJob job = new( ) { + Id = jobId, + TaskId = taskId, + TaskSnapshot = taskSnapshot, + RuntimeSeconds = ( endTime - startTime ).TotalSeconds, + StartTime = startTime, + EndTime = endTime, + Success = success, + AgentConnectionId = Guid.TryParse( connection.ConnectionId, out Guid connId ) ? connId : null, + ExitCode = exitCode, + ErrorCategory = errorCategory, + Output = outputPreview, + OutputPath = AgentJobOutputWriter.GetRelativeOutputPath( jobId ), + ScheduleId = scheduleId, + }; + + if (!string.IsNullOrWhiteSpace( workflowRunId ) && Guid.TryParse( workflowRunId, out Guid wfRunId )) { + job.WorkflowRunId = wfRunId; + } + + _ = dbContext.Jobs.Add( job ); + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Persisted job {JobId} locally for task {TaskId}.", jobId, taskId ); + } + } catch (Exception ex) { + logger.LogWarning( ex, "Failed to persist job {JobId} locally. Server report will still be attempted.", jobId ); + } + } + // ── Proto ↔ Schedule Mapping ───────────────────────────────────────────────── /// @@ -910,6 +1143,7 @@ internal static Schedule MapProtoToSchedule( ScheduleDefinition def ) { DbSchedule = new DbSchedule { Id = Guid.TryParse( def.ScheduleId, out Guid sid ) ? sid : Guid.Empty, StopTaskAfterMinutes = def.StopTaskAfterMinutes, + CatchUpEnabled = def.CatchUpEnabled, }, StartDateTime = startDt, Expiration = expiration, diff --git a/src/Werkr.Agent/Scheduling/WorkflowExecutionService.cs b/src/Werkr.Agent/Scheduling/WorkflowExecutionService.cs new file mode 100644 index 0000000..184e87d --- /dev/null +++ b/src/Werkr.Agent/Scheduling/WorkflowExecutionService.cs @@ -0,0 +1,733 @@ +using System.Text.Json; +using Werkr.Agent.Communication; +using Werkr.Agent.Operators; +using Werkr.Common.Models.Actions; +using Werkr.Common.Protos; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Core.Tasks; +using Werkr.Core.Workflows; +using Werkr.Data; +using Werkr.Data.Entities.Tasks; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Agent.Scheduling; + +/// +/// Executes workflows locally on the Agent using the same operator infrastructure +/// as . Handles DAG-ordered step execution +/// with control flow (If/Else/ElseIf/While/Do), dependency modes, and per-step +/// job reporting. This replaces the server-side WorkflowExecutor that +/// dispatched to agents via gRPC. +/// +/// Writes job output to local disk. +/// Evaluates success criteria. +/// Evaluates control flow condition expressions. +/// PowerShell operator. +/// System shell operator. +/// Built-in action operator. +/// Factory for creating outbound gRPC clients to the Server. +/// Factory for creating DI scopes to resolve scoped services (e.g. WerkrDbContext). +/// Logger. +public sealed class WorkflowExecutionService( + AgentJobOutputWriter outputWriter, + SuccessCriteriaEvaluator successEvaluator, + ConditionEvaluator conditionEvaluator, + PwshOperator pwshOperator, + SystemShellOperator shellOperator, + IActionOperator actionOperator, + AgentGrpcClientFactory clientFactory, + IServiceScopeFactory serviceScopeFactory, + ILogger logger +) { + + // ── Result Types ───────────────────────────────────────────────────────────── + + /// Result of executing a single workflow step. + private sealed record StepExecutionResult( + long StepId, + StepJobResult? Job, + bool Failed, + bool WasSkipped, + string? ErrorMessage + ) { + public static StepExecutionResult Ok( long stepId, StepJobResult job ) => + new( stepId, job, Failed: false, WasSkipped: false, ErrorMessage: null ); + + public static StepExecutionResult Skipped( long stepId ) => + new( stepId, Job: null, Failed: false, WasSkipped: true, ErrorMessage: null ); + + public static StepExecutionResult Fail( long stepId, string errorMessage ) => + new( stepId, Job: null, Failed: true, WasSkipped: false, ErrorMessage: errorMessage ); + } + + /// Captures the essential result of executing a step's task for dependency evaluation. + internal sealed record StepJobResult( + Guid JobId, + bool Success, + int ExitCode, + DateTime StartTime, + DateTime EndTime, + ErrorCategory ErrorCategory, + string? OutputPreview + ); + + // ── Public API ─────────────────────────────────────────────────────────────── + + /// + /// Executes a workflow locally using DAG topological ordering. + /// Each step runs via the local operator infrastructure and results + /// are reported to the server individually. + /// + /// The workflow definition from the schedule sync. + /// Cancellation token. + public async Task ExecuteWorkflowLocallyAsync( + ScheduledWorkflowDefinition workflow, + CancellationToken ct + ) { + Guid workflowRunId = Guid.NewGuid(); + + // Resolve schedule ID from the workflow definition + Guid? scheduleId = workflow.Schedule is not null + && Guid.TryParse( workflow.Schedule.ScheduleId, out Guid sid ) + ? sid : null; + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Starting local workflow execution {RunId} for workflow {WorkflowId} '{WorkflowName}' ({StepCount} steps).", + workflowRunId, workflow.WorkflowId, workflow.Name, workflow.Steps.Count ); + } + + // Build topological levels from the step definitions + IReadOnlyList> levels = + BuildTopologicalLevels( workflow.Steps ); + + // Step result map: stepId → completed job result + Dictionary stepResults = []; + + // If/Else/ElseIf chain tracking: stepId → whether that branch was taken + Dictionary branchTaken = []; + + bool workflowFailed = false; + + try { + foreach (IReadOnlyList level in levels) { + ct.ThrowIfCancellationRequested( ); + + // Partition steps into chain-bound (If/Else/ElseIf) and parallelizable + List parallelizable = []; + List chainBound = []; + + foreach (ScheduledWorkflowStepDef step in level) { + ControlStatement cs = (ControlStatement) step.ControlStatement; + if (cs is ControlStatement.If or ControlStatement.Else or ControlStatement.ElseIf) { + chainBound.Add( step ); + } else { + parallelizable.Add( step ); + } + } + + // Execute parallelizable steps (sequentially — no DB context concerns on agent + // but keeps behavior consistent with the original executor) + foreach (ScheduledWorkflowStepDef step in parallelizable) { + ct.ThrowIfCancellationRequested( ); + + StepExecutionResult result = await ExecuteStepAsync( + step, workflow, workflowRunId, stepResults, branchTaken, scheduleId, ct ); + + if (result.Job is not null) { + stepResults[result.StepId] = result.Job; + } + + if (result.Failed) { + logger.LogWarning( + "Workflow run {RunId} failed at step {StepId}: {Error}.", + workflowRunId, result.StepId, result.ErrorMessage ); + workflowFailed = true; + break; + } + } + + if (workflowFailed) { + break; + } + + // Execute chain-bound steps sequentially (order matters for If/Else evaluation) + foreach (ScheduledWorkflowStepDef step in chainBound) { + ct.ThrowIfCancellationRequested( ); + + StepExecutionResult result = await ExecuteStepAsync( + step, workflow, workflowRunId, stepResults, branchTaken, scheduleId, ct ); + + if (result.Job is not null) { + stepResults[result.StepId] = result.Job; + } + + if (result.Failed) { + logger.LogWarning( + "Workflow run {RunId} failed at step {StepId}: {Error}.", + workflowRunId, result.StepId, result.ErrorMessage ); + workflowFailed = true; + break; + } + } + + if (workflowFailed) { + break; + } + } + + if (!workflowFailed && logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( + "Workflow run {RunId} completed successfully ({StepCount} steps executed).", + workflowRunId, stepResults.Count ); + } + } catch (OperationCanceledException) when (ct.IsCancellationRequested) { + throw; // Propagate shutdown + } catch (Exception ex) { + logger.LogError( ex, "Workflow run {RunId} failed with unexpected error.", workflowRunId ); + } + } + + // ── Step Execution ─────────────────────────────────────────────────────────── + + /// Executes a single workflow step, handling control flow. + private async Task ExecuteStepAsync( + ScheduledWorkflowStepDef step, + ScheduledWorkflowDefinition workflow, + Guid workflowRunId, + Dictionary stepResults, + Dictionary branchTaken, + Guid? scheduleId, + CancellationToken ct + ) { + ScheduledTaskDefinition? taskDef = step.Task; + if (taskDef is null) { + string msg = $"Step {step.StepId} has no embedded task definition."; + return StepExecutionResult.Fail( step.StepId, msg ); + } + + string stepLabel = $"Step {step.Order}: {taskDef.Name}"; + + // Check dependency satisfaction + if (!CheckDependencies( step, stepResults )) { + string depError = $"Dependencies not satisfied for step {step.StepId} (DependencyMode={(DependencyMode) step.DependencyMode})."; + return StepExecutionResult.Fail( step.StepId, depError ); + } + + // Gather predecessor jobs for condition evaluation + List predecessorJobs = BuildPredecessorJobs( step, stepResults ); + + // Evaluate control flow + bool shouldExecute = EvaluateControlFlow( step, predecessorJobs, branchTaken ); + + if (!shouldExecute) { + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Step {StepId} '{StepLabel}' skipped by control flow ({ControlStatement}).", + step.StepId, stepLabel, (ControlStatement)step.ControlStatement ); + } + return StepExecutionResult.Skipped( step.StepId ); + } + + // Handle While/Do loops + ControlStatement cs = (ControlStatement) step.ControlStatement; + if (cs is ControlStatement.While or ControlStatement.Do) { + return await ExecuteLoopStepAsync( + step, taskDef, workflowRunId, predecessorJobs, scheduleId, ct ); + } + + // Execute the step's task locally + StepJobResult job = await ExecuteStepTaskAsync( taskDef, workflowRunId, scheduleId, ct ); + + // Record branch taken for If/ElseIf chains + if (cs is ControlStatement.If or ControlStatement.ElseIf) { + branchTaken[step.StepId] = true; + } + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Step {StepId} '{StepLabel}' executed: Success={Success}, JobId={JobId}.", + step.StepId, stepLabel, job.Success, job.JobId ); + } + + return StepExecutionResult.Ok( step.StepId, job ); + } + + /// Executes a While or Do loop step. + private async Task ExecuteLoopStepAsync( + ScheduledWorkflowStepDef step, + ScheduledTaskDefinition taskDef, + Guid workflowRunId, + List predecessorJobs, + Guid? scheduleId, + CancellationToken ct + ) { + StepJobResult? lastJob = null; + int iterations = 0; + bool isDoLoop = (ControlStatement) step.ControlStatement == ControlStatement.Do; + int maxIterations = step.MaxIterations > 0 ? step.MaxIterations : 100; + + while (iterations < maxIterations) { + ct.ThrowIfCancellationRequested( ); + + // For While: check condition before execution + // For Do: execute first, then check condition + if (!isDoLoop || iterations > 0) { + IReadOnlyList evalJobs = lastJob is not null + ? [ToWerkrJob( lastJob )] + : predecessorJobs; + bool conditionMet = conditionEvaluator.EvaluateMultiple( + step.ConditionExpression, evalJobs, (DependencyMode) step.DependencyMode ); + if (!conditionMet) { + break; + } + } + + lastJob = await ExecuteStepTaskAsync( taskDef, workflowRunId, scheduleId, ct ); + iterations++; + + if (!lastJob.Success) { + break; + } + } + + if (iterations >= maxIterations) { + logger.LogWarning( "Step {StepId} reached MaxIterations ({Max}).", + step.StepId, maxIterations ); + } + + return lastJob is not null + ? StepExecutionResult.Ok( step.StepId, lastJob ) + : StepExecutionResult.Skipped( step.StepId ); + } + + // ── Task Execution (mirrors ScheduleEvaluatorService.ExecuteTaskLocallyAsync) ── + + /// + /// Executes a single task locally and reports the result to the server. + /// + private async Task ExecuteStepTaskAsync( + ScheduledTaskDefinition taskDef, + Guid workflowRunId, + Guid? scheduleId, + CancellationToken ct + ) { + Guid jobId = Guid.NewGuid(); + DateTime startTime = DateTime.UtcNow; + + TaskActionType actionType = (TaskActionType) taskDef.ActionType; + List collectedOutput = []; + int exitCode = 0; + Exception? executionException = null; + ErrorCategory errorCategory = ErrorCategory.None; + + try { + using CancellationTokenSource timeoutCts = taskDef.TimeoutMinutes > 0 + ? CancellationTokenSource.CreateLinkedTokenSource( ct ) + : CancellationTokenSource.CreateLinkedTokenSource( ct ); + + if (taskDef.TimeoutMinutes > 0) { + timeoutCts.CancelAfter( TimeSpan.FromMinutes( taskDef.TimeoutMinutes ) ); + } + + OperatorExecution execution = RunOperator( taskDef, actionType, timeoutCts.Token ); + + await foreach (OperatorOutput output in execution.Output.WithCancellation( timeoutCts.Token )) { + await outputWriter.WriteLineAsync( jobId, output, timeoutCts.Token ); + collectedOutput.Add( output ); + } + + IOperatorResult result = await execution.Result; + exitCode = result switch { + ShellOperatorResult shell => shell.ExitCode, + PwshOperatorResult pwsh => pwsh.LastExitCode ?? (pwsh.HadErrors ? 1 : 0), + _ => result.Success ? 0 : 1, + }; + executionException = result.Exception; + + if (!result.Success && errorCategory == ErrorCategory.None) { + errorCategory = ErrorCategory.ScriptError; + } + } catch (OperationCanceledException) when (ct.IsCancellationRequested) { + throw; // Propagate shutdown + } catch (OperationCanceledException) { + errorCategory = ErrorCategory.Timeout; + exitCode = -1; + OperatorOutput timeoutMsg = OperatorOutput.Create( "Error", + $"Task '{taskDef.Name}' exceeded timeout of {taskDef.TimeoutMinutes} minutes." ); + await outputWriter.WriteLineAsync( jobId, timeoutMsg, ct ); + collectedOutput.Add( timeoutMsg ); + } catch (Exception ex) { + executionException = ex; + errorCategory = ErrorCategory.ScriptError; + exitCode = -1; + OperatorOutput errorMsg = OperatorOutput.Create( "Error", $"Execution error: {ex.Message}" ); + await outputWriter.WriteLineAsync( jobId, errorMsg, ct ); + collectedOutput.Add( errorMsg ); + } + + DateTime endTime = DateTime.UtcNow; + + bool success = successEvaluator.Evaluate( + actionType, + string.IsNullOrWhiteSpace( taskDef.SuccessCriteria ) ? null : taskDef.SuccessCriteria, + exitCode, + collectedOutput, + executionException ); + + string? tailPreview = await outputWriter.GetTailPreviewAsync( jobId, ct ); + + // Persist job locally in the agent's SQLite database + await PersistJobLocallyAsync( jobId, taskDef.TaskId, taskDef.Content, startTime, endTime, + success, exitCode, errorCategory, tailPreview, workflowRunId.ToString( ), scheduleId, ct ); + + // Report result to server with workflowRunId and schedule ID + await ReportJobResultAsync( + jobId, taskDef, startTime, endTime, success, exitCode, + errorCategory, workflowRunId.ToString( ), tailPreview, scheduleId, ct ); + + return new StepJobResult( jobId, success, exitCode, startTime, endTime, errorCategory, tailPreview ); + } + + // ── Operator Selection (mirrors ScheduleEvaluatorService.RunOperator) ──────── + + /// + /// Selects and invokes the appropriate operator based on the task's action type. + /// + private OperatorExecution RunOperator( + ScheduledTaskDefinition taskDef, + TaskActionType actionType, + CancellationToken ct + ) { + if (actionType == TaskActionType.Action) { + using JsonDocument parsedParameters = JsonDocument.Parse( taskDef.ActionParametersJson ); + ActionDescriptor descriptor = new() { + Action = taskDef.ActionSubType, + Parameters = parsedParameters.RootElement.Clone(), + }; + + return actionOperator.Execute( descriptor, ct ); + } + + IShellOperator operator_ = actionType switch { + TaskActionType.PowerShellCommand or TaskActionType.PowerShellScript => pwshOperator, + TaskActionType.ShellCommand or TaskActionType.ShellScript => shellOperator, + _ => throw new NotSupportedException( + $"ActionType '{actionType}' is not supported for local execution." ), + }; + + return actionType switch { + TaskActionType.PowerShellCommand or TaskActionType.ShellCommand => + operator_.RunCommand( taskDef.Content, ct ), + + TaskActionType.PowerShellScript or TaskActionType.ShellScript when taskDef.Arguments.Count > 0 => + operator_.RunScriptWithArgs( taskDef.Content, taskDef.Arguments, ct ), + + TaskActionType.PowerShellScript or TaskActionType.ShellScript => + operator_.RunScript( taskDef.Content, ct ), + + _ => throw new NotSupportedException( + $"ActionType '{actionType}' is not supported for local execution." ), + }; + } + + // ── DAG Topological Sort ───────────────────────────────────────────────────── + + /// + /// Builds topological levels from proto step definitions using Kahn's algorithm. + /// Steps at the same level have all dependencies satisfied by prior levels. + /// + private static IReadOnlyList> BuildTopologicalLevels( + IReadOnlyCollection steps + ) { + // Build adjacency: stepId → set of dependents + Dictionary> dependents = []; + Dictionary inDegree = []; + Dictionary stepMap = []; + + foreach (ScheduledWorkflowStepDef step in steps) { + stepMap[step.StepId] = step; + _ = inDegree.TryAdd( step.StepId, 0 ); + _ = dependents.TryAdd( step.StepId, [] ); + } + + foreach (ScheduledWorkflowStepDef step in steps) { + foreach (long depId in step.DependsOnStepIds) { + if (dependents.TryGetValue( depId, out List? depList )) { + depList.Add( step.StepId ); + } + inDegree[step.StepId] = inDegree.GetValueOrDefault( step.StepId ) + 1; + } + } + + List> levels = []; + Queue queue = new(); + + foreach ((long id, int deg) in inDegree) { + if (deg == 0) { + queue.Enqueue( id ); + } + } + + while (queue.Count > 0) { + List level = []; + int levelSize = queue.Count; + + for (int i = 0; i < levelSize; i++) { + long id = queue.Dequeue(); + level.Add( stepMap[id] ); + + foreach (long depId in dependents.GetValueOrDefault( id, [] )) { + inDegree[depId]--; + if (inDegree[depId] == 0) { + queue.Enqueue( depId ); + } + } + } + + // Sort by order within level for deterministic execution + level.Sort( ( a, b ) => a.Order.CompareTo( b.Order ) ); + levels.Add( level ); + } + + return levels; + } + + // ── Control Flow ───────────────────────────────────────────────────────────── + + /// Checks whether dependencies are satisfied based on DependencyMode. + private static bool CheckDependencies( + ScheduledWorkflowStepDef step, + Dictionary stepResults + ) { + if (step.DependsOnStepIds.Count == 0) { + return true; // Root step — no dependencies + } + + DependencyMode mode = (DependencyMode) step.DependencyMode; + + return mode switch { + DependencyMode.All => + step.DependsOnStepIds.All( id => stepResults.ContainsKey( id ) ), + DependencyMode.Any => + step.DependsOnStepIds.Any( id => stepResults.ContainsKey( id ) ), + _ => step.DependsOnStepIds.All( id => stepResults.ContainsKey( id ) ), + }; + } + + /// Evaluates control flow to determine if a step should execute. + private bool EvaluateControlFlow( + ScheduledWorkflowStepDef step, + List predecessorJobs, + Dictionary branchTaken + ) { + ControlStatement cs = (ControlStatement) step.ControlStatement; + + switch (cs) { + case ControlStatement.Sequential: + return true; + + case ControlStatement.If: + bool ifResult = conditionEvaluator.EvaluateMultiple( + step.ConditionExpression, predecessorJobs, (DependencyMode) step.DependencyMode ); + branchTaken[step.StepId] = ifResult; + return ifResult; + + case ControlStatement.ElseIf: { + bool priorTaken = step.DependsOnStepIds.Any( id => + branchTaken.TryGetValue( id, out bool taken ) && taken ); + if (priorTaken) { + branchTaken[step.StepId] = false; + return false; + } + bool elseIfResult = conditionEvaluator.EvaluateMultiple( + step.ConditionExpression, predecessorJobs, (DependencyMode) step.DependencyMode ); + branchTaken[step.StepId] = elseIfResult; + return elseIfResult; + } + + case ControlStatement.Else: { + bool anyPriorTaken = step.DependsOnStepIds.Any( id => + branchTaken.TryGetValue( id, out bool taken ) && taken ); + return !anyPriorTaken; + } + + case ControlStatement.While: + case ControlStatement.Do: + return true; + + default: + return true; + } + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + /// + /// Builds lightweight instances from step results + /// for use by the which expects WerkrJob inputs. + /// + private static List BuildPredecessorJobs( + ScheduledWorkflowStepDef step, + Dictionary stepResults + ) { + List jobs = []; + foreach (long depId in step.DependsOnStepIds) { + if (stepResults.TryGetValue( depId, out StepJobResult? result )) { + jobs.Add( ToWerkrJob( result ) ); + } + } + return jobs; + } + + /// + /// Converts a to a lightweight + /// with just enough data for condition evaluation. + /// + private static WerkrJob ToWerkrJob( StepJobResult result ) => new( ) { + Id = result.JobId, + Success = result.Success, + ExitCode = result.ExitCode, + StartTime = result.StartTime, + EndTime = result.EndTime, + ErrorCategory = result.ErrorCategory, + }; + + // ── Local Job Persistence ──────────────────────────────────────────────────── + + /// + /// Persists a completed job to the agent's local SQLite database. + /// Creates a DI scope to resolve a scoped . + /// + private async Task PersistJobLocallyAsync( + Guid jobId, + long taskId, + string taskSnapshot, + DateTime startTime, + DateTime endTime, + bool success, + int exitCode, + ErrorCategory errorCategory, + string? outputPreview, + string? workflowRunId, + Guid? scheduleId, + CancellationToken ct + ) { + try { + await using AsyncServiceScope scope = serviceScopeFactory.CreateAsyncScope( ); + WerkrDbContext dbContext = scope.ServiceProvider.GetRequiredService( ); + + Data.Entities.Registration.RegisteredConnection connection = await clientFactory.GetConnectionAsync( ct ); + + WerkrJob job = new( ) { + Id = jobId, + TaskId = taskId, + TaskSnapshot = taskSnapshot, + RuntimeSeconds = ( endTime - startTime ).TotalSeconds, + StartTime = startTime, + EndTime = endTime, + Success = success, + AgentConnectionId = connection.Id, + ExitCode = exitCode, + ErrorCategory = errorCategory, + Output = outputPreview, + OutputPath = AgentJobOutputWriter.GetRelativeOutputPath( jobId ), + ScheduleId = scheduleId, + }; + + if (!string.IsNullOrWhiteSpace( workflowRunId ) && Guid.TryParse( workflowRunId, out Guid wfRunId )) { + job.WorkflowRunId = wfRunId; + } + + _ = dbContext.Jobs.Add( job ); + _ = await dbContext.SaveChangesAsync( ct ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Persisted step job {JobId} locally for task {TaskId}.", jobId, taskId ); + } + } catch (Exception ex) { + logger.LogWarning( ex, "Failed to persist step job {JobId} locally. Server report will still be attempted.", jobId ); + } + } + + // ── Job Reporting ──────────────────────────────────────────────────────────── + + /// Maximum number of retry attempts for reporting a job result. + private const int ReportMaxRetries = 3; + + /// Initial delay between retry attempts. + private static readonly TimeSpan s_reportRetryBaseDelay = TimeSpan.FromSeconds( 2 ); + + /// + /// Reports a completed job result to the Server via gRPC with retry on transient failures. + /// + private async Task ReportJobResultAsync( + Guid jobId, + ScheduledTaskDefinition taskDef, + DateTime startTime, + DateTime endTime, + bool success, + int exitCode, + ErrorCategory errorCategory, + string? workflowRunId, + string? outputPreview, + Guid? scheduleId, + CancellationToken ct + ) { + JobReporting.JobReportingClient client = await clientFactory.CreateJobReportingClientAsync( ct ); + Data.Entities.Registration.RegisteredConnection connection = await clientFactory.GetConnectionAsync( ct ); + + JobResultRequest innerRequest = new() { + ConnectionId = connection.Id.ToString(), + TaskId = taskDef.TaskId, + TaskSnapshot = taskDef.Content, + RuntimeSeconds = ( endTime - startTime ).TotalSeconds, + StartTime = startTime.ToString( "o" ), + EndTime = endTime.ToString( "o" ), + Success = success, + ExitCode = exitCode, + ErrorCategory = (int) errorCategory, + OutputPath = AgentJobOutputWriter.GetRelativeOutputPath( jobId ), + JobId = jobId.ToString( ), + }; + if (!string.IsNullOrWhiteSpace( workflowRunId )) { + innerRequest.WorkflowRunId = workflowRunId; + } + if (!string.IsNullOrWhiteSpace( outputPreview )) { + innerRequest.OutputPreview = outputPreview; + } + if (scheduleId.HasValue) { + innerRequest.ScheduleId = scheduleId.Value.ToString( ); + } + + EncryptedEnvelope requestEnvelope = PayloadEncryptor.EncryptToEnvelope( + innerRequest, clientFactory.GetSharedKey(), clientFactory.GetKeyId() ); + + TimeSpan delay = s_reportRetryBaseDelay; + for (int attempt = 1; attempt <= ReportMaxRetries; attempt++) { + try { + Grpc.Core.CallOptions callOptions = clientFactory.CreateCallOptions( cancellationToken: ct ); + EncryptedEnvelope responseEnvelope = await client.ReportJobResultAsync( requestEnvelope, callOptions ); + JobResultResponse response = PayloadEncryptor.DecryptFromEnvelope( + responseEnvelope, clientFactory.GetSharedKey() ); + + if (response.Accepted) { + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Step job result reported. Server JobId={ServerJobId}.", response.JobId ); + } + } else { + logger.LogWarning( "Server rejected step job result for task {TaskId}.", taskDef.TaskId ); + } + return; + } catch (Exception ex) when (attempt < ReportMaxRetries && !ct.IsCancellationRequested) { + logger.LogWarning( ex, + "Failed to report step job result for task {TaskId} (attempt {Attempt}/{MaxRetries}). Retrying in {Delay}.", + taskDef.TaskId, attempt, ReportMaxRetries, delay ); + await Task.Delay( delay, ct ); + delay *= 2; + } catch (Exception ex) { + logger.LogError( ex, "Failed to report step job result for task {TaskId} after {MaxRetries} attempts.", + taskDef.TaskId, ReportMaxRetries ); + } + } + } +} diff --git a/src/Werkr.Agent/Services/ActionService.cs b/src/Werkr.Agent/Services/ActionService.cs deleted file mode 100644 index 58c6920..0000000 --- a/src/Werkr.Agent/Services/ActionService.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Text.Json; -using Grpc.Core; -using Werkr.Agent.Protos; -using Werkr.Common.Models.Actions; -using Werkr.Common.Protos; -using Werkr.Core.Communication; -using Werkr.Core.Operators; -using Werkr.Data.Entities.Registration; - -namespace Werkr.Agent.Services; - -/// -/// gRPC service implementation for built-in action operations. -/// Authenticates via , decrypts -/// requests, streams encrypted output back in envelopes. -/// -/// Creates a new . -/// The action operator for dispatching built-in actions. -/// Logger for diagnostics. -public class ActionService( - IActionOperator actionOperator, - ILogger logger -) : Werkr.Agent.Protos.Action.ActionBase { - - /// - public override async Task RunAction( - EncryptedEnvelope request, - IServerStreamWriter responseStream, - ServerCallContext context - ) { - - RegisteredConnection connection = GetConnection( context ); - - ActionRequest actionRequest = PayloadEncryptor.DecryptFromEnvelope( - request, connection.SharedKey ); - - if (logger.IsEnabled( LogLevel.Debug )) { - logger.LogDebug( "Executing action '{ActionName}' for connection {ConnectionId}.", - actionRequest.ActionName, connection.Id.ToString( ) ); - } - - using JsonDocument parsedParameters = JsonDocument.Parse( actionRequest.ParametersJson ); - ActionDescriptor descriptor = new( ) { - Action = actionRequest.ActionName, - Parameters = parsedParameters.RootElement.Clone( ), - }; - - string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); - OperatorExecution execution = actionOperator.Execute( descriptor, context.CancellationToken ); - await OperatorOutputAdapter.StreamToGrpc( - execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); - } - - private static RegisteredConnection GetConnection( ServerCallContext context ) { - return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection - ? connection - : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); - } -} diff --git a/src/Werkr.Agent/Services/OutputStreamingService.cs b/src/Werkr.Agent/Services/OutputStreamingService.cs new file mode 100644 index 0000000..97e2f91 --- /dev/null +++ b/src/Werkr.Agent/Services/OutputStreamingService.cs @@ -0,0 +1,219 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using Grpc.Core; +using Werkr.Agent.Communication; +using Werkr.Common.Protos; + +namespace Werkr.Agent.Services; + +/// +/// Manages a persistent bidirectional gRPC stream to the Server for real-time +/// output delivery. The agent opens the stream on startup and pushes +/// lines as tasks execute. The server sends +/// requests back to control which tasks the +/// agent publishes output for. +/// +/// When no subscriptions are active, output is still written to disk and the +/// local database by ; it is +/// simply not pushed over the stream. +/// +/// +/// A bounded ring buffer of the last lines per +/// executing task is maintained. Late-arriving subscriptions receive the +/// buffered lines before switching to live output. +/// +/// +/// Factory for creating outbound gRPC clients. +/// Logger. +public sealed class OutputStreamingService( + AgentGrpcClientFactory clientFactory, + ILogger logger +) : IDisposable { + + /// Maximum number of output lines buffered per executing task. + internal const int BufferCapacity = 100; + + // ── Subscription tracking ──────────────────────────────────────────────────── + + /// Key for identifying a specific task execution. + private readonly record struct ExecutionKey( long TaskId, string ScheduleId ); + + /// Active subscriptions requested by the server. + private readonly ConcurrentDictionary _subscriptions = new( ); + + /// Ring buffers holding the last N output lines per execution. + private readonly ConcurrentDictionary _buffers = new( ); + + /// Channel used to send messages over the gRPC stream. + private readonly Channel _outbound = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true } ); + + private CancellationTokenSource? _streamCts; + + // ── Public API (called by ScheduleEvaluatorService) ────────────────────────── + + /// + /// Publishes an output message. The line is always buffered. If a + /// matching subscription is active the message is also enqueued for the + /// gRPC stream. + /// + public void Publish( OutputMessage message ) { + ExecutionKey key = new( message.TaskId, message.ScheduleId ); + + // Always buffer + BoundedBuffer buffer = _buffers.GetOrAdd( key, _ => new BoundedBuffer( BufferCapacity ) ); + buffer.Add( message ); + + // Only push to stream if subscribed + if (_subscriptions.ContainsKey( key )) { + _ = _outbound.Writer.TryWrite( message ); + } + } + + /// + /// Removes the ring buffer for a completed execution, freeing memory. + /// + public void ClearBuffer( long taskId, string scheduleId ) { + ExecutionKey key = new( taskId, scheduleId ); + _ = _buffers.TryRemove( key, out _ ); + } + + // ── Stream Lifecycle ───────────────────────────────────────────────────────── + + /// + /// Opens the bidirectional stream to the server and begins reading + /// subscription requests. Intended to be called once after registration + /// completes. Reconnection with exponential backoff is handled internally. + /// + public void Start( CancellationToken appShutdown ) { + _streamCts = CancellationTokenSource.CreateLinkedTokenSource( appShutdown ); + _ = Task.Run( ( ) => MaintainStreamAsync( _streamCts.Token ), _streamCts.Token ); + } + + /// + public void Dispose( ) { + _streamCts?.Cancel( ); + _streamCts?.Dispose( ); + } + + // ── Internal loop ──────────────────────────────────────────────────────────── + + private async Task MaintainStreamAsync( CancellationToken ct ) { + TimeSpan delay = TimeSpan.FromSeconds( 2 ); + TimeSpan maxDelay = TimeSpan.FromSeconds( 60 ); + + while (!ct.IsCancellationRequested) { + try { + Werkr.Common.Protos.OutputStreamingService.OutputStreamingServiceClient client = + await clientFactory.CreateOutputStreamingClientAsync( ct ); + CallOptions callOptions = clientFactory.CreateCallOptions( cancellationToken: ct ); + + using AsyncDuplexStreamingCall call = + client.StreamOutput( callOptions ); + + // Reset backoff on successful connect + delay = TimeSpan.FromSeconds( 2 ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Output streaming connected to server." ); + } + + // Read subscriptions in the background + Task readTask = ReadSubscriptionsAsync( call.ResponseStream, ct ); + + // Write outbound messages + await WriteOutputAsync( call.RequestStream, ct ); + + await readTask; + } catch (OperationCanceledException) when (ct.IsCancellationRequested) { + break; + } catch (RpcException ex) when (ct.IsCancellationRequested) { + logger.LogDebug( ex, "Output stream RPC cancelled during shutdown." ); + break; + } catch (Exception ex) { + logger.LogWarning( ex, "Output stream disconnected. Reconnecting in {Delay}s.", delay.TotalSeconds ); + _subscriptions.Clear( ); + try { + await Task.Delay( delay, ct ); + } catch (OperationCanceledException) { + break; + } + delay = TimeSpan.FromTicks( Math.Min( delay.Ticks * 2, maxDelay.Ticks ) ); + } + } + } + + private async Task ReadSubscriptionsAsync( + IAsyncStreamReader reader, + CancellationToken ct + ) { + await foreach (OutputSubscription sub in reader.ReadAllAsync( ct )) { + ExecutionKey key = new( sub.TaskId, sub.ScheduleId ); + + if (sub.Subscribe) { + _ = _subscriptions.TryAdd( key, true ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Subscribed to output for task {TaskId} schedule {ScheduleId}.", + sub.TaskId, sub.ScheduleId ); + } + + // Replay buffered lines + if (_buffers.TryGetValue( key, out BoundedBuffer? buffer )) { + foreach (OutputMessage buffered in buffer.Snapshot( )) { + _ = _outbound.Writer.TryWrite( buffered ); + } + } + } else { + _ = _subscriptions.TryRemove( key, out _ ); + + if (logger.IsEnabled( LogLevel.Debug )) { + logger.LogDebug( "Unsubscribed from output for task {TaskId} schedule {ScheduleId}.", + sub.TaskId, sub.ScheduleId ); + } + } + } + } + + private async Task WriteOutputAsync( + IClientStreamWriter writer, + CancellationToken ct + ) { + await foreach (OutputMessage message in _outbound.Reader.ReadAllAsync( ct )) { + await writer.WriteAsync( message, ct ); + } + } + + // ── Ring Buffer ────────────────────────────────────────────────────────────── + + /// + /// Simple thread-safe bounded ring buffer that keeps the last N items. + /// + private sealed class BoundedBuffer( int capacity ) { + private readonly OutputMessage[] _items = new OutputMessage[capacity]; + private readonly Lock _lock = new( ); + private int _head; + private int _count; + + public void Add( OutputMessage item ) { + lock (_lock) { + _items[_head] = item; + _head = (_head + 1) % capacity; + if (_count < capacity) { + _count++; + } + } + } + + public IReadOnlyList Snapshot( ) { + lock (_lock) { + OutputMessage[] snapshot = new OutputMessage[_count]; + int start = (_head - _count + capacity) % capacity; + for (int i = 0; i < _count; i++) { + snapshot[i] = _items[(start + i) % capacity]; + } + return snapshot; + } + } + } +} diff --git a/src/Werkr.Agent/Services/PwshService.cs b/src/Werkr.Agent/Services/PwshService.cs deleted file mode 100644 index cc350dd..0000000 --- a/src/Werkr.Agent/Services/PwshService.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Grpc.Core; -using Microsoft.Extensions.Options; -using Werkr.Agent.Operators; -using Werkr.Agent.Protos; -using Werkr.Common.Configuration; -using Werkr.Common.Protos; -using Werkr.Core.Communication; -using Werkr.Core.Operators; -using Werkr.Data.Entities.Registration; - -namespace Werkr.Agent.Services; - -/// -/// gRPC service implementation for PowerShell operations. -/// Authenticates via , decrypts -/// requests, streams encrypted output back in envelopes. -/// -/// Creates a new . -/// The PowerShell operator. -/// Agent settings from configuration. -/// Logger for diagnostics. -public class PwshService( - PwshOperator pwshOperator, - IOptions agentSettingsOptions, - ILogger logger -) : Pwsh.PwshBase { - private readonly AgentSettings _agentSettings = agentSettingsOptions.Value; - - /// - public override async Task RunCommand( - EncryptedEnvelope request, - IServerStreamWriter responseStream, - ServerCallContext context - ) { - - ValidatePowerShellEnabled( ); - RegisteredConnection connection = GetConnection( context ); - - ShellRequest shellRequest = PayloadEncryptor.DecryptFromEnvelope( request, connection.SharedKey ); - if (logger.IsEnabled( LogLevel.Debug )) { - logger.LogDebug( "Executing PowerShell command for connection {ConnectionId}.", connection.Id.ToString( ) ); - } - - string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); - OperatorExecution execution = pwshOperator.RunCommand( shellRequest.Command, context.CancellationToken ); - await OperatorOutputAdapter.StreamToGrpc( execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); - } - - /// - public override async Task RunScript( - EncryptedEnvelope request, - IServerStreamWriter responseStream, - ServerCallContext context - ) { - - ValidatePowerShellEnabled( ); - RegisteredConnection connection = GetConnection( context ); - - ShellRequest shellRequest = PayloadEncryptor.DecryptFromEnvelope( request, connection.SharedKey ); - if (logger.IsEnabled( LogLevel.Debug )) { - logger.LogDebug( "Executing PowerShell script for connection {ConnectionId}.", connection.Id.ToString( ) ); - } - - string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); - OperatorExecution execution = pwshOperator.RunScript( shellRequest.Command, context.CancellationToken ); - await OperatorOutputAdapter.StreamToGrpc( execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); - } - - /// - public override async Task RunScriptWithArgs( - EncryptedEnvelope request, - IServerStreamWriter responseStream, - ServerCallContext context - ) { - - ValidatePowerShellEnabled( ); - RegisteredConnection connection = GetConnection( context ); - - ScriptRequest scriptRequest = PayloadEncryptor.DecryptFromEnvelope( request, connection.SharedKey ); - - if (logger.IsEnabled( LogLevel.Debug )) { - logger.LogDebug( "Executing PowerShell script with args for connection {ConnectionId}.", connection.Id.ToString( ) ); - } - - string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); - OperatorExecution execution = pwshOperator.RunScriptWithArgs( scriptRequest.Script, [.. scriptRequest.Args], context.CancellationToken ); - await OperatorOutputAdapter.StreamToGrpc( execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); - } - - private void ValidatePowerShellEnabled( ) { - if (!_agentSettings.EnablePowerShell) { - throw new RpcException( new Status( StatusCode.Unimplemented, "PowerShell is disabled on this agent." ) ); - } - } - - private static RegisteredConnection GetConnection( ServerCallContext context ) { - return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection - ? connection - : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); - } -} diff --git a/src/Werkr.Agent/Services/SystemShellService.cs b/src/Werkr.Agent/Services/SystemShellService.cs deleted file mode 100644 index 617d690..0000000 --- a/src/Werkr.Agent/Services/SystemShellService.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Grpc.Core; -using Microsoft.Extensions.Options; -using Werkr.Agent.Operators; -using Werkr.Agent.Protos; -using Werkr.Common.Configuration; -using Werkr.Common.Protos; -using Werkr.Core.Communication; -using Werkr.Core.Operators; -using Werkr.Data.Entities.Registration; - -namespace Werkr.Agent.Services; - -/// -/// gRPC service implementation for system shell operations (cmd.exe / bash). -/// Authenticates via , decrypts -/// requests, streams encrypted output back in envelopes. -/// -/// Creates a new . -/// The system shell operator. -/// Agent settings from configuration. -/// Logger for diagnostics. -public class SystemShellService( - SystemShellOperator shellOperator, - IOptions agentSettingsOptions, - ILogger logger -) : SystemShell.SystemShellBase { - private readonly AgentSettings _agentSettings = agentSettingsOptions.Value; - - /// - public override async Task RunCommand( - EncryptedEnvelope request, - IServerStreamWriter responseStream, - ServerCallContext context - ) { - - ValidateSystemShellEnabled( ); - RegisteredConnection connection = GetConnection( context ); - - ShellRequest shellRequest = PayloadEncryptor.DecryptFromEnvelope( request, connection.SharedKey ); - if (logger.IsEnabled( LogLevel.Debug )) { - logger.LogDebug( "Executing system shell command for connection {ConnectionId}.", connection.Id.ToString( ) ); - } - - string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); - OperatorExecution execution = shellOperator.RunCommand( shellRequest.Command, context.CancellationToken ); - await OperatorOutputAdapter.StreamToGrpc( execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); - } - - /// - public override async Task RunScript( - EncryptedEnvelope request, - IServerStreamWriter responseStream, - ServerCallContext context - ) { - - ValidateSystemShellEnabled( ); - RegisteredConnection connection = GetConnection( context ); - - ShellRequest shellRequest = PayloadEncryptor.DecryptFromEnvelope( request, connection.SharedKey ); - if (logger.IsEnabled( LogLevel.Debug )) { - logger.LogDebug( "Executing system shell script for connection {ConnectionId}.", connection.Id.ToString( ) ); - } - - string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); - OperatorExecution execution = shellOperator.RunScript( shellRequest.Command, context.CancellationToken ); - await OperatorOutputAdapter.StreamToGrpc( execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); - } - - /// - public override async Task RunScriptWithArgs( - EncryptedEnvelope request, - IServerStreamWriter responseStream, - ServerCallContext context - ) { - - ValidateSystemShellEnabled( ); - RegisteredConnection connection = GetConnection( context ); - - ScriptRequest scriptRequest = PayloadEncryptor.DecryptFromEnvelope( request, connection.SharedKey ); - - if (logger.IsEnabled( LogLevel.Debug )) { - logger.LogDebug( "Executing system shell script with args for connection {ConnectionId}.", connection.Id.ToString( ) ); - } - - string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); - OperatorExecution execution = shellOperator.RunScriptWithArgs( scriptRequest.Script, [.. scriptRequest.Args], context.CancellationToken ); - await OperatorOutputAdapter.StreamToGrpc( execution.Output, responseStream, connection.SharedKey, keyId, context.CancellationToken ); - } - - private void ValidateSystemShellEnabled( ) { - if (!_agentSettings.EnableSystemShell) { - throw new RpcException( new Status( StatusCode.Unimplemented, "System shell is disabled on this agent." ) ); - } - } - - private static RegisteredConnection GetConnection( ServerCallContext context ) { - return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection - ? connection - : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); - } -} diff --git a/src/Werkr.Agent/Werkr.Agent.csproj b/src/Werkr.Agent/Werkr.Agent.csproj index b7adbc5..c4b8d3d 100644 --- a/src/Werkr.Agent/Werkr.Agent.csproj +++ b/src/Werkr.Agent/Werkr.Agent.csproj @@ -19,16 +19,16 @@ - - + + and imported via ProjectReference. --> diff --git a/src/Werkr.Api/Endpoints/AgentEndpoints.cs b/src/Werkr.Api/Endpoints/AgentEndpoints.cs index e0b4cb3..ebf0550 100644 --- a/src/Werkr.Api/Endpoints/AgentEndpoints.cs +++ b/src/Werkr.Api/Endpoints/AgentEndpoints.cs @@ -1,13 +1,17 @@ using Grpc.Core; using Grpc.Net.Client; using Microsoft.EntityFrameworkCore; +using Werkr.Api.Services; using Werkr.Common.Auth; using Werkr.Common.Models; using Werkr.Common.Protos; + using Werkr.Core.Communication; using Werkr.Core.Cryptography; +using Werkr.Core.Scheduling; using Werkr.Data; using Werkr.Data.Entities.Registration; +using Werkr.Data.Entities.Tasks; // KeyRotationService used for POST /api/agents/{id}/rotate-key @@ -340,6 +344,9 @@ CancellationToken ct /// /// Registers the command execution endpoint for dispatching commands to a specific agent. + /// Creates an ephemeral task and a one-time schedule so the agent picks it up + /// through the normal schedule engine. Returns 202 Accepted with the task and + /// schedule IDs for subsequent polling. /// private static void MapAgentExecute( WebApplication app ) { _ = app.MapPost( @@ -347,23 +354,32 @@ private static void MapAgentExecute( WebApplication app ) { async ( Guid agentId, ExecuteCommandRequest request, - CommandDispatcher commandDispatcher, + RunNowService runNowService, + ScheduleInvalidationDispatcher invalidationDispatcher, + WerkrDbContext dbContext, CancellationToken ct ) => { - using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource( ct ); - if (request.TimeoutMinutes > 0) { - cts.CancelAfter( TimeSpan.FromMinutes( request.TimeoutMinutes ) ); - } + TaskActionType actionType = request.ActionType.HasValue + ? (TaskActionType) request.ActionType.Value + : TaskActionType.ShellCommand; - OperatorType operatorType = Enum.Parse( request.OperatorType, ignoreCase: true ); - List results = []; + // Look up the agent's tags so the ephemeral task routes to this agent + string[]? agentTags = await dbContext.RegisteredConnections + .AsNoTracking( ) + .Where( c => c.Id == agentId && c.IsServer ) + .Select( c => c.Tags ) + .FirstOrDefaultAsync( ct ); - await foreach (OperatorOutput output in commandDispatcher.ExecuteCommandAsync( - agentId, operatorType, request.Command, cts.Token )) { - results.Add( new OperatorOutputLine( output.LogLevel, output.Message, output.Timestamp ) ); - } + (long taskId, Guid scheduleId) = await runNowService.CreateEphemeralTaskAsync( + request.Command, actionType, agentTags, ct ); + + await invalidationDispatcher.InvalidateAsync( scheduleId, ct ); - return Results.Ok( new ExecuteCommandResponse( true, results ) ); + return Results.Accepted( value: new { + taskId, + scheduleId, + message = "Ephemeral task created. Execution will begin on the next agent sync.", + } ); } ) .WithName( "ExecuteCommand" ) .RequireAuthorization( Policies.CanExecute ); diff --git a/src/Werkr.Api/Endpoints/EventEndpoints.cs b/src/Werkr.Api/Endpoints/EventEndpoints.cs new file mode 100644 index 0000000..dc717f9 --- /dev/null +++ b/src/Werkr.Api/Endpoints/EventEndpoints.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using Werkr.Common.Auth; +using Werkr.Core.Communication; + +namespace Werkr.Api.Endpoints; + +/// +/// Maps Server-Sent Events (SSE) endpoints for real-time job event streaming. +/// Clients (e.g. the Blazor Server) subscribe to /api/events/jobs to receive +/// live notifications when agents report job completions. +/// +internal static class EventEndpoints { + + private static readonly JsonSerializerOptions s_jsonOptions = new( ) { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// Maps the /api/events/jobs SSE endpoint. + public static WebApplication MapEventEndpoints( this WebApplication app ) { + _ = app.MapGet( "/api/events/jobs", async ( + JobEventBroadcaster broadcaster, + HttpContext httpContext, + CancellationToken ct + ) => { + httpContext.Response.ContentType = "text/event-stream"; + httpContext.Response.Headers.CacheControl = "no-cache"; + httpContext.Response.Headers.Connection = "keep-alive"; + + using JobEventSubscription subscription = broadcaster.Subscribe( ); + + try { + await foreach (JobEvent jobEvent in subscription.Reader.ReadAllAsync( ct )) { + string json = JsonSerializer.Serialize( jobEvent, s_jsonOptions ); + string eventType = jobEvent.WorkflowRunId.HasValue + ? "workflow-job" + : "task-job"; + + await httpContext.Response.WriteAsync( $"event: {eventType}\n", ct ); + await httpContext.Response.WriteAsync( $"data: {json}\n\n", ct ); + await httpContext.Response.Body.FlushAsync( ct ); + } + } catch (OperationCanceledException) { + // Client disconnected — normal SSE lifecycle. + } + } ) + .WithName( "StreamJobEvents" ) + .RequireAuthorization( Policies.CanRead ) + .ExcludeFromDescription( ); + + return app; + } +} diff --git a/src/Werkr.Api/Endpoints/ShellEndpoints.cs b/src/Werkr.Api/Endpoints/ShellEndpoints.cs index 8e923d7..d7ca68e 100644 --- a/src/Werkr.Api/Endpoints/ShellEndpoints.cs +++ b/src/Werkr.Api/Endpoints/ShellEndpoints.cs @@ -1,67 +1,114 @@ using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Werkr.Api.Services; using Werkr.Common.Auth; using Werkr.Common.Models; -using Werkr.Core.Communication; +using Werkr.Common.Protos; +using Werkr.Core.Scheduling; +using Werkr.Data; +using Werkr.Data.Entities.Tasks; namespace Werkr.Api.Endpoints; -/// Maps the SSE streaming shell-execution endpoint. +/// +/// Maps the interactive shell streaming endpoint. A browser client POSTs a +/// command to /api/agents/{agentId}/shell/stream; the server creates an +/// ephemeral task + one-time schedule, pushes a schedule invalidation to the +/// agent, subscribes to the output stream, and SSE-forwards each line to the +/// browser in real time. +/// internal static class ShellEndpoints { - /// - /// Static configured with camelCase property naming for serializing SSE data payloads. - /// + private static readonly JsonSerializerOptions s_jsonOptions = new( ) { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; - /// - /// Maps POST /api/agents/{agentId}/shell/stream - streams operator - /// output as Server-Sent Events so the Blazor UI can render lines in real time. - /// + /// Maps the shell streaming endpoint. public static WebApplication MapShellEndpoints( this WebApplication app ) { _ = app.MapPost( "/api/agents/{agentId}/shell/stream", async ( Guid agentId, ExecuteCommandRequest request, - CommandDispatcher commandDispatcher, + RunNowService runNowService, + ScheduleInvalidationDispatcher invalidationDispatcher, + OutputStreamingGrpcService outputStreaming, + WerkrDbContext dbContext, HttpContext httpContext, CancellationToken ct ) => { - using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource( ct ); - if (request.TimeoutMinutes > 0) { - cts.CancelAfter( TimeSpan.FromMinutes( request.TimeoutMinutes ) ); + TaskActionType actionType = request.ActionType.HasValue + ? (TaskActionType) request.ActionType.Value + : TaskActionType.ShellCommand; + + // Look up the agent's tags so the ephemeral task routes to this agent + string[]? agentTags = await dbContext.RegisteredConnections + .AsNoTracking( ) + .Where( c => c.Id == agentId && c.IsServer ) + .Select( c => c.Tags ) + .FirstOrDefaultAsync( ct ); + + // Create ephemeral task + one-time schedule + (long taskId, Guid scheduleId) = await runNowService.CreateEphemeralTaskAsync( + request.Command, actionType, agentTags, ct ); + + // Push invalidation so the agent picks it up immediately + await invalidationDispatcher.InvalidateAsync( scheduleId, ct ); + + // Subscribe to the output stream from the agent + string scheduleIdStr = scheduleId.ToString( ); + System.Threading.Channels.Channel? channel = + await outputStreaming.SubscribeAsync( taskId, scheduleIdStr ); + + if (channel is null) { + // No agent stream available yet — return 202 with IDs so client can poll + httpContext.Response.StatusCode = StatusCodes.Status202Accepted; + await httpContext.Response.WriteAsJsonAsync( new { + taskId, + scheduleId, + message = "Ephemeral task created. No agent stream available for live output.", + }, ct ); + return; } + // Stream output as SSE httpContext.Response.ContentType = "text/event-stream"; httpContext.Response.Headers.CacheControl = "no-cache"; httpContext.Response.Headers.Connection = "keep-alive"; - httpContext.Response.Headers["X-Accel-Buffering"] = "no"; // disable nginx buffering - OperatorType operatorType = Enum.Parse( request.OperatorType, ignoreCase: true ); + // Send initial metadata so client knows the task/schedule IDs + string meta = JsonSerializer.Serialize( new { taskId, scheduleId }, s_jsonOptions ); + await httpContext.Response.WriteAsync( $"event: meta\ndata: {meta}\n\n", ct ); + await httpContext.Response.Body.FlushAsync( ct ); try { - await foreach (OperatorOutput output in commandDispatcher.ExecuteCommandAsync( - agentId, operatorType, request.Command, cts.Token )) { - OperatorOutputLine line = new( output.LogLevel, output.Message, output.Timestamp ); - string json = JsonSerializer.Serialize( line, s_jsonOptions ); - - await httpContext.Response.WriteAsync( $"data: {json}\n\n", cts.Token ); - await httpContext.Response.Body.FlushAsync( cts.Token ); + await foreach (OutputMessage message in channel.Reader.ReadAllAsync( ct )) { + if (message.PayloadCase == OutputMessage.PayloadOneofCase.Line) { + string json = JsonSerializer.Serialize( new { + message = message.Line.Text, + logLevel = message.Line.LogLevel, + timestamp = message.Line.Timestamp, + }, s_jsonOptions ); + await httpContext.Response.WriteAsync( $"event: output\ndata: {json}\n\n", ct ); + await httpContext.Response.Body.FlushAsync( ct ); + } else if (message.PayloadCase == OutputMessage.PayloadOneofCase.Complete) { + string json = JsonSerializer.Serialize( new { + exitCode = message.Complete.ExitCode, + success = message.Complete.Success, + errorMessage = message.Complete.ErrorMessage, + }, s_jsonOptions ); + await httpContext.Response.WriteAsync( $"event: complete\ndata: {json}\n\n", ct ); + await httpContext.Response.Body.FlushAsync( ct ); + break; // Execution is done + } } - - // Signal completion - await httpContext.Response.WriteAsync( "event: done\ndata: {}\n\n", cts.Token ); - await httpContext.Response.Body.FlushAsync( cts.Token ); } catch (OperationCanceledException) { - // Client disconnected or timeout — write nothing further - } catch (CommandDispatcherException ex) { - string errorJson = JsonSerializer.Serialize( - new { message = ex.UserMessage }, s_jsonOptions ); - await httpContext.Response.WriteAsync( $"event: error\ndata: {errorJson}\n\n", CancellationToken.None ); - await httpContext.Response.Body.FlushAsync( CancellationToken.None ); + // Client disconnected — normal SSE lifecycle. + } finally { + await outputStreaming.UnsubscribeAsync( taskId, scheduleIdStr ); } } ) - .WithName( "StreamShellExecute" ) - .RequireAuthorization( Policies.CanExecute ); + .WithName( "StreamShellOutput" ) + .RequireAuthorization( Policies.CanExecute ) + .ExcludeFromDescription( ); return app; } diff --git a/src/Werkr.Api/Endpoints/TaskEndpoints.cs b/src/Werkr.Api/Endpoints/TaskEndpoints.cs index 5753dcb..1e46c98 100644 --- a/src/Werkr.Api/Endpoints/TaskEndpoints.cs +++ b/src/Werkr.Api/Endpoints/TaskEndpoints.cs @@ -1,7 +1,8 @@ using Werkr.Api.Models; +using Werkr.Api.Services; using Werkr.Common.Auth; using Werkr.Common.Models; -using Werkr.Core.Communication; +using Werkr.Core.Scheduling; using Werkr.Core.Tasks; using Werkr.Data.Entities.Tasks; @@ -105,28 +106,38 @@ CancellationToken ct .WithName( "SetTaskEnabled" ) .RequireAuthorization( Policies.CanUpdate ); + // ── Run Now: creates a one-time schedule and invalidates agents ── _ = app.MapPost( "/api/tasks/{id}/run", async ( long id, TaskRunRequest? request, - JobExecutionService jobExecutionService, + RunNowService runNowService, + ScheduleInvalidationDispatcher invalidationDispatcher, CancellationToken ct ) => { try { - WerkrJob job = await jobExecutionService.ExecuteAsync( id, ct ); - return Results.Ok( TaskMapper.ToJobDto( job ) ); + Guid scheduleId = await runNowService.CreateTaskRunNowAsync( id, ct ); + await invalidationDispatcher.InvalidateAsync( scheduleId, ct ); + return Results.Accepted( $"/api/tasks/{id}/latest-job", + new { scheduleId, message = "One-time schedule created. Execution will begin on the next agent sync." } ); } catch (KeyNotFoundException) { - return Results.NotFound( new { message = $"Task with Id={id} was not found." } ); - } catch (InvalidOperationException ex) { - return Results.Conflict( new { message = ex.Message } ); - } catch (CommandDispatcherException ex) { - return Results.Json( - new { message = ex.UserMessage }, - statusCode: 502 ); + return Results.NotFound( ); } } ) .WithName( "RunTask" ) .RequireAuthorization( Policies.CanExecute ); + // ── Latest Job: convenience endpoint for polling after Run Now ── + _ = app.MapGet( "/api/tasks/{id}/latest-job", async ( + long id, + JobExecutionService jobService, + CancellationToken ct + ) => { + IReadOnlyList jobs = await jobService.GetJobHistoryAsync( id, limit: 1, ct ); + return jobs.Count == 0 ? Results.NotFound( ) : Results.Ok( jobs[0] ); + } ) + .WithName( "GetLatestJobForTask" ) + .RequireAuthorization( Policies.CanRead ); + return app; } } diff --git a/src/Werkr.Api/Endpoints/WorkflowEndpoints.cs b/src/Werkr.Api/Endpoints/WorkflowEndpoints.cs index c021151..06e16d8 100644 --- a/src/Werkr.Api/Endpoints/WorkflowEndpoints.cs +++ b/src/Werkr.Api/Endpoints/WorkflowEndpoints.cs @@ -1,8 +1,14 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Werkr.Api.Models; +using Werkr.Api.Services; using Werkr.Common.Auth; using Werkr.Common.Models; +using Werkr.Core.Communication; +using Werkr.Core.Scheduling; using Werkr.Core.Workflows; +using Werkr.Data; using Werkr.Data.Entities.Workflows; namespace Werkr.Api.Endpoints; @@ -274,7 +280,8 @@ CancellationToken ct _ = app.MapPost( "/api/workflows/{id}/run", async ( long id, WorkflowService workflowService, - WorkflowExecutor workflowExecutor, + RunNowService runNowService, + ScheduleInvalidationDispatcher invalidationDispatcher, CancellationToken ct ) => { Workflow? workflow = await workflowService.GetByIdAsync( id, ct ); @@ -286,8 +293,10 @@ CancellationToken ct return Results.BadRequest( new { message = "Workflow is disabled." } ); } - WorkflowRun run = await workflowExecutor.ExecuteAsync( workflow, ct ); - return Results.Ok( WorkflowMapper.ToRunDto( run ) ); + Guid scheduleId = await runNowService.CreateWorkflowRunNowAsync( id, ct ); + await invalidationDispatcher.InvalidateAsync( scheduleId, ct ); + return Results.Accepted( $"/api/workflows/{id}/runs", + new { scheduleId, message = "One-time schedule created. Execution will begin on the next agent sync." } ); } ) .WithName( "RunWorkflow" ) .RequireAuthorization( Policies.CanExecute ); @@ -295,11 +304,14 @@ CancellationToken ct _ = app.MapGet( "/api/workflows/{id}/runs", async ( long id, int? limit, - WorkflowExecutor workflowExecutor, + WerkrDbContext dbContext, CancellationToken ct ) => { - IReadOnlyList runs = await workflowExecutor.GetRunsAsync( - id, limit ?? 50, ct ); + IReadOnlyList runs = await dbContext.WorkflowRuns.AsNoTracking( ) + .Where( r => r.WorkflowId == id ) + .OrderByDescending( r => r.StartTime ) + .Take( limit ?? 50 ) + .ToListAsync( ct ); List dtos = [.. runs.Select( WorkflowMapper.ToRunDto )]; return Results.Ok( dtos ); } ) @@ -308,10 +320,12 @@ CancellationToken ct _ = app.MapGet( "/api/workflows/runs/{runId}", async ( Guid runId, - WorkflowExecutor workflowExecutor, + WerkrDbContext dbContext, CancellationToken ct ) => { - WorkflowRun? run = await workflowExecutor.GetRunAsync( runId, ct ); + WorkflowRun? run = await dbContext.WorkflowRuns.AsNoTracking( ) + .Include( r => r.Jobs ) + .FirstOrDefaultAsync( r => r.Id == runId, ct ); return run is null ? Results.NotFound( ) : Results.Ok( WorkflowMapper.ToRunDetailDto( run ) ); @@ -319,16 +333,49 @@ CancellationToken ct .WithName( "GetWorkflowRun" ) .RequireAuthorization( Policies.CanRead ); - _ = app.MapGet( "/api/workflows/runs/{runId}/stream", ( + _ = app.MapGet( "/api/workflows/runs/{runId}/stream", async ( Guid runId, - WorkflowRunTracker tracker + JobEventBroadcaster broadcaster, + WerkrDbContext dbContext, + HttpContext httpContext, + CancellationToken ct ) => { - IAsyncEnumerable? updates = tracker.GetUpdates( runId ); - return updates is null - ? Results.NotFound( ) - : Results.Ok( updates ); + // Verify the run exists before opening the SSE stream. + bool exists = await dbContext.WorkflowRuns.AsNoTracking( ) + .AnyAsync( r => r.Id == runId, ct ); + if (!exists) { + httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + httpContext.Response.ContentType = "text/event-stream"; + httpContext.Response.Headers.CacheControl = "no-cache"; + httpContext.Response.Headers.Connection = "keep-alive"; + + JsonSerializerOptions jsonOptions = new( ) { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + using JobEventSubscription subscription = broadcaster.Subscribe( ); + + try { + await foreach (JobEvent jobEvent in subscription.Reader.ReadAllAsync( ct )) { + // Only forward events belonging to this workflow run. + if (jobEvent.WorkflowRunId != runId) { + continue; + } + + string json = JsonSerializer.Serialize( jobEvent, jsonOptions ); + await httpContext.Response.WriteAsync( $"event: workflow-job\n", ct ); + await httpContext.Response.WriteAsync( $"data: {json}\n\n", ct ); + await httpContext.Response.Body.FlushAsync( ct ); + } + } catch (OperationCanceledException) { + // Client disconnected — normal SSE lifecycle. + } } ) .WithName( "StreamWorkflowRunUpdates" ) - .RequireAuthorization( Policies.CanRead ); + .RequireAuthorization( Policies.CanRead ) + .ExcludeFromDescription( ); } } diff --git a/src/Werkr.Api/Models/TaskMapper.cs b/src/Werkr.Api/Models/TaskMapper.cs index caf17ec..74c0c56 100644 --- a/src/Werkr.Api/Models/TaskMapper.cs +++ b/src/Werkr.Api/Models/TaskMapper.cs @@ -50,7 +50,6 @@ public static WerkrTask ToEntity( TaskCreateRequest request ) { Enabled = request.Enabled, TimeoutMinutes = request.TimeoutMinutes, SuccessCriteria = request.SuccessCriteria, - ScheduleId = request.ScheduleId, WorkflowId = request.WorkflowId, ActionSubType = request.ActionSubType, ActionParameters = request.ActionParameters, @@ -72,7 +71,6 @@ public static WerkrTask ToEntity( long id, TaskUpdateRequest request ) { Enabled = request.Enabled, TimeoutMinutes = request.TimeoutMinutes, SuccessCriteria = request.SuccessCriteria, - ScheduleId = request.ScheduleId, WorkflowId = request.WorkflowId, ActionSubType = request.ActionSubType, ActionParameters = request.ActionParameters, @@ -95,7 +93,6 @@ public static TaskDto ToDto( WerkrTask task ) => SuccessCriteria: task.SuccessCriteria, EffectiveSuccessCriteria: SuccessCriteriaEvaluator.DescribeEffectiveCriteria( task.ActionType, task.SuccessCriteria ), - ScheduleId: task.ScheduleId, WorkflowId: task.WorkflowId, ActionSubType: task.ActionSubType, ActionParameters: task.ActionParameters ); diff --git a/src/Werkr.Api/Models/WorkflowMapper.cs b/src/Werkr.Api/Models/WorkflowMapper.cs index 4d1ca15..612201f 100644 --- a/src/Werkr.Api/Models/WorkflowMapper.cs +++ b/src/Werkr.Api/Models/WorkflowMapper.cs @@ -14,7 +14,6 @@ public static Workflow ToEntity( WorkflowCreateRequest request ) => Name = request.Name, Description = request.Description ?? string.Empty, Enabled = request.Enabled, - ScheduleId = request.ScheduleId, }; /// Maps a to a entity with a given ID. @@ -24,7 +23,6 @@ public static Workflow ToEntity( long id, WorkflowUpdateRequest request ) => Name = request.Name, Description = request.Description ?? string.Empty, Enabled = request.Enabled, - ScheduleId = request.ScheduleId, }; /// Maps a entity to a . @@ -34,7 +32,6 @@ public static WorkflowDto ToDto( Workflow workflow ) => Name: workflow.Name, Description: workflow.Description, Enabled: workflow.Enabled, - ScheduleId: workflow.ScheduleId, Steps: [.. workflow.Steps.Select( ToStepDto )] ); /// Maps a entity to a . diff --git a/src/Werkr.Api/Program.cs b/src/Werkr.Api/Program.cs index 753032a..b474a15 100644 --- a/src/Werkr.Api/Program.cs +++ b/src/Werkr.Api/Program.cs @@ -141,6 +141,12 @@ public static async Task Main( string[] args ) { // Agent connection manager (Singleton — caches gRPC channels) _ = builder.Services.AddSingleton( ); + // Job event broadcaster (Singleton — SSE fan-out for real-time push) + _ = builder.Services.AddSingleton( ); + + // Output streaming gRPC service (Singleton — receives agent output streams) + _ = builder.Services.AddSingleton( ); + // Agent health check background service — keeps DB status current _ = builder.Services.AddHostedService( sp => { IServiceScopeFactory scopeFactory = sp.GetRequiredService( ); @@ -150,12 +156,9 @@ public static async Task Main( string[] args ) { return new Werkr.Core.Health.AgentHealthCheckService( scopeFactory, connectionManager, logger ); } ); - // Command dispatcher (Scoped — one per request) - _ = builder.Services.AddScoped( ); - _ = builder.Services.AddScoped( sp => sp.GetRequiredService( ) ); - // Schedule service (Scoped — one per request) _ = builder.Services.AddScoped( ); + _ = builder.Services.AddScoped( ); // Task & Job services (Scoped — one per request) _ = builder.Services.Configure( @@ -169,8 +172,6 @@ public static async Task Main( string[] args ) { // Workflow services (Scoped — one per request) _ = builder.Services.AddScoped( ); _ = builder.Services.AddScoped( ); - _ = builder.Services.AddScoped( ); - _ = builder.Services.AddSingleton( ); // Schedule invalidation dispatcher (Scoped — sends push notifications to agents) _ = builder.Services.AddScoped( ); @@ -220,7 +221,7 @@ public static async Task Main( string[] args ) { _ = app.MapGrpcService( ); _ = app.MapGrpcService( ); _ = app.MapGrpcService( ); - _ = app.MapGrpcService( ); + _ = app.MapGrpcService( ); // REST endpoints _ = app.MapStatusEndpoints( ); @@ -232,9 +233,10 @@ public static async Task Main( string[] args ) { _ = app.MapTaskEndpoints( ); _ = app.MapJobEndpoints( ); _ = app.MapSettingsEndpoints( ); - _ = app.MapShellEndpoints( ); _ = app.MapWorkflowEndpoints( ); _ = app.MapHolidayCalendarEndpoints( ); + _ = app.MapEventEndpoints( ); + _ = app.MapShellEndpoints( ); _ = app.MapDefaultEndpoints( ); diff --git a/src/Werkr.Api/Services/JobReportingGrpcService.cs b/src/Werkr.Api/Services/JobReportingGrpcService.cs index 7df133e..ccf5322 100644 --- a/src/Werkr.Api/Services/JobReportingGrpcService.cs +++ b/src/Werkr.Api/Services/JobReportingGrpcService.cs @@ -1,4 +1,5 @@ using Grpc.Core; +using Microsoft.EntityFrameworkCore; using Werkr.Common.Protos; using Werkr.Core.Communication; using Werkr.Data; @@ -13,9 +14,11 @@ namespace Werkr.Api.Services; /// All RPCs use . /// /// Database context. +/// Singleton broadcaster for SSE push notifications. /// Logger instance. public sealed class JobReportingGrpcService( WerkrDbContext dbContext, + JobEventBroadcaster broadcaster, ILogger logger ) : JobReporting.JobReportingBase { @@ -64,24 +67,74 @@ ServerCallContext context ? wfRunId : null; - WerkrJob job = new( ) { - TaskId = inner.TaskId, - TaskSnapshot = inner.TaskSnapshot, - RuntimeSeconds = inner.RuntimeSeconds, - StartTime = startTime, - EndTime = endTime, - Success = inner.Success, - AgentConnectionId = connectionId, - ExitCode = inner.ExitCode, - ErrorCategory = errorCategory, - Output = string.IsNullOrWhiteSpace( inner.OutputPreview ) ? null : inner.OutputPreview, - OutputPath = inner.OutputPath, - WorkflowRunId = workflowRunId, - }; + // Parse agent-assigned job ID for upsert (if provided) + Guid? agentJobId = !string.IsNullOrWhiteSpace( inner.JobId ) + && Guid.TryParse( inner.JobId, out Guid parsedJobId ) + ? parsedJobId + : null; + + // Parse schedule ID (if provided) + Guid? scheduleId = !string.IsNullOrWhiteSpace( inner.ScheduleId ) + && Guid.TryParse( inner.ScheduleId, out Guid parsedScheduleId ) + ? parsedScheduleId + : null; - _ = dbContext.Jobs.Add( job ); + // Upsert: if agent provided a job_id, check for existing job + WerkrJob? job = agentJobId.HasValue + ? await dbContext.Jobs.FirstOrDefaultAsync( j => j.Id == agentJobId.Value, context.CancellationToken ) + : null; + + if (job is not null) { + // Update existing job (agent persisted it first, now server catches up) + job.TaskId = inner.TaskId; + job.TaskSnapshot = inner.TaskSnapshot; + job.RuntimeSeconds = inner.RuntimeSeconds; + job.StartTime = startTime; + job.EndTime = endTime; + job.Success = inner.Success; + job.AgentConnectionId = connectionId; + job.ExitCode = inner.ExitCode; + job.ErrorCategory = errorCategory; + job.Output = string.IsNullOrWhiteSpace( inner.OutputPreview ) ? null : inner.OutputPreview; + job.OutputPath = inner.OutputPath; + job.WorkflowRunId = workflowRunId; + job.ScheduleId = scheduleId; + } else { + // Create new job + job = new( ) { + TaskId = inner.TaskId, + TaskSnapshot = inner.TaskSnapshot, + RuntimeSeconds = inner.RuntimeSeconds, + StartTime = startTime, + EndTime = endTime, + Success = inner.Success, + AgentConnectionId = connectionId, + ExitCode = inner.ExitCode, + ErrorCategory = errorCategory, + Output = string.IsNullOrWhiteSpace( inner.OutputPreview ) ? null : inner.OutputPreview, + OutputPath = inner.OutputPath, + WorkflowRunId = workflowRunId, + ScheduleId = scheduleId, + }; + if (agentJobId.HasValue) { + job.Id = agentJobId.Value; + } + _ = dbContext.Jobs.Add( job ); + } _ = await dbContext.SaveChangesAsync( context.CancellationToken ); + // Broadcast to SSE subscribers after the job is safely persisted. + broadcaster.Publish( new JobEvent( + JobId: job.Id, + TaskId: job.TaskId, + WorkflowRunId: job.WorkflowRunId, + Success: job.Success, + ExitCode: job.ExitCode, + RuntimeSeconds: job.RuntimeSeconds, + AgentConnectionId: connectionId, + Timestamp: DateTime.UtcNow + ) ); + if (logger.IsEnabled( LogLevel.Information )) { logger.LogInformation( "Persisted job {JobId} from agent {AgentId}: Task={TaskId}, Success={Success}, Runtime={Runtime:F1}s.", diff --git a/src/Werkr.Api/Services/OutputStreamingGrpcService.cs b/src/Werkr.Api/Services/OutputStreamingGrpcService.cs new file mode 100644 index 0000000..132ab03 --- /dev/null +++ b/src/Werkr.Api/Services/OutputStreamingGrpcService.cs @@ -0,0 +1,147 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using Grpc.Core; +using Werkr.Common.Protos; + +namespace Werkr.Api.Services; + +/// +/// Server-side gRPC handler that receives the persistent bidirectional output stream +/// from agents. When a browser client subscribes via SSE, this service sends an +/// to the appropriate agent so it starts pushing +/// lines, which are then forwarded to SSE consumers. +/// +/// Registered as a singleton. Each connected agent holds one +/// that wraps the duplex call. +/// +/// +/// Logger. +public sealed class OutputStreamingGrpcService( + ILogger logger +) : Werkr.Common.Protos.OutputStreamingService.OutputStreamingServiceBase { + + // ── Agent stream tracking ──────────────────────────────────────────────────── + + /// + /// Holds per-agent stream state: the response writer for sending subscriptions + /// back to the agent and a set of SSE consumers keyed by execution. + /// + private sealed class AgentStream { + public required IServerStreamWriter ResponseWriter { get; init; } + public ConcurrentDictionary> Consumers { get; } = new( ); + } + + /// All currently connected agent streams keyed by peer address. + private readonly ConcurrentDictionary _agents = new( ); + + // ── gRPC Entry Point ───────────────────────────────────────────────────────── + + /// + /// Called once per agent when it opens the persistent output stream. + /// Reads incoming and fans them out to + /// any registered SSE consumers. + /// + public override async Task StreamOutput( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context + ) { + string peer = context.Peer ?? Guid.NewGuid( ).ToString( ); + + AgentStream agentStream = new( ) { ResponseWriter = responseStream }; + _ = _agents.TryAdd( peer, agentStream ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Agent output stream connected from {Peer}.", peer ); + } + + try { + await foreach (OutputMessage message in requestStream.ReadAllAsync( context.CancellationToken )) { + string consumerKey = BuildConsumerKey( message.TaskId, message.ScheduleId ); + + // Fan out to any SSE consumers for this execution + foreach (KeyValuePair> kvp in agentStream.Consumers) { + if (kvp.Key == consumerKey) { + _ = kvp.Value.Writer.TryWrite( message ); + } + } + } + } catch (OperationCanceledException) { + // Agent disconnected or server shutting down + } catch (Exception ex) { + logger.LogWarning( ex, "Agent output stream from {Peer} ended with error.", peer ); + } finally { + _ = _agents.TryRemove( peer, out _ ); + + if (logger.IsEnabled( LogLevel.Information )) { + logger.LogInformation( "Agent output stream disconnected from {Peer}.", peer ); + } + } + } + + // ── Public API (called by SSE endpoints) ───────────────────────────────────── + + /// + /// Subscribes a browser SSE consumer to output for a specific task/schedule + /// execution. Sends an to all connected agents + /// so that matching output is pushed over the stream. + /// + /// The task to subscribe to. + /// The schedule to subscribe to. + /// + /// A that the SSE endpoint can read from, + /// or null if no agent stream is available. + /// + public async Task?> SubscribeAsync( + long taskId, + string scheduleId + ) { + string consumerKey = BuildConsumerKey( taskId, scheduleId ); + Channel channel = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true } ); + + bool any = false; + foreach (KeyValuePair kvp in _agents) { + _ = kvp.Value.Consumers.TryAdd( consumerKey, channel ); + try { + await kvp.Value.ResponseWriter.WriteAsync( new OutputSubscription { + TaskId = taskId, + ScheduleId = scheduleId, + Subscribe = true, + } ); + any = true; + } catch (Exception ex) { + logger.LogWarning( ex, "Failed to send subscribe to agent {Peer}.", kvp.Key ); + } + } + + return any ? channel : null; + } + + /// + /// Unsubscribes a browser SSE consumer from output for a specific execution. + /// + /// The task to unsubscribe from. + /// The schedule to unsubscribe from. + public async Task UnsubscribeAsync( long taskId, string scheduleId ) { + string consumerKey = BuildConsumerKey( taskId, scheduleId ); + + foreach (KeyValuePair kvp in _agents) { + _ = kvp.Value.Consumers.TryRemove( consumerKey, out _ ); + try { + await kvp.Value.ResponseWriter.WriteAsync( new OutputSubscription { + TaskId = taskId, + ScheduleId = scheduleId, + Subscribe = false, + } ); + } catch (Exception ex) { + logger.LogWarning( ex, "Failed to send unsubscribe to agent {Peer}.", kvp.Key ); + } + } + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private static string BuildConsumerKey( long taskId, string scheduleId ) + => $"{taskId}:{scheduleId}"; +} diff --git a/src/Werkr.Api/Services/ScheduleInvalidationDispatcher.cs b/src/Werkr.Api/Services/ScheduleInvalidationDispatcher.cs index 4e2778b..501e2d4 100644 --- a/src/Werkr.Api/Services/ScheduleInvalidationDispatcher.cs +++ b/src/Werkr.Api/Services/ScheduleInvalidationDispatcher.cs @@ -39,9 +39,10 @@ ILogger logger WerkrDbContext db = scope.ServiceProvider.GetRequiredService( ); // Find tasks referencing this schedule to get their TargetTags - List affectedTasks = await db.Tasks + List affectedTasks = await db.TaskSchedules .AsNoTracking( ) - .Where( t => t.ScheduleId == scheduleId ) + .Where( ts => ts.ScheduleId == scheduleId ) + .Select( ts => ts.Task! ) .ToListAsync( ct ); if (affectedTasks.Count == 0) { diff --git a/src/Werkr.Api/Services/ScheduleSyncGrpcService.cs b/src/Werkr.Api/Services/ScheduleSyncGrpcService.cs index d1d600d..906a031 100644 --- a/src/Werkr.Api/Services/ScheduleSyncGrpcService.cs +++ b/src/Werkr.Api/Services/ScheduleSyncGrpcService.cs @@ -64,18 +64,21 @@ ServerCallContext context AgentScheduleResponse response = new( ); // ── Standalone tasks with schedules ── - List tasks = await dbContext.Tasks + List taskSchedules = await dbContext.TaskSchedules .AsNoTracking( ) - .Where( t => t.Enabled && t.ScheduleId != null ) + .Include( ts => ts.Task ) + .Include( ts => ts.Schedule ) + .Where( ts => ts.Task!.Enabled ) .ToListAsync( context.CancellationToken ); - foreach (WerkrTask task in tasks) { + foreach (TaskSchedule ts in taskSchedules) { + WerkrTask task = ts.Task!; // Tag intersection (in-memory for JSON column compatibility) if (!task.TargetTags.Any( tag => agentTags.Contains( tag.Trim( ) ) )) { continue; } - Schedule? schedule = await scheduleService.GetByIdAsync( task.ScheduleId!.Value, context.CancellationToken ); + Schedule? schedule = await scheduleService.GetByIdAsync( ts.ScheduleId, context.CancellationToken ); if (schedule is null) { continue; } @@ -85,16 +88,19 @@ ServerCallContext context } // ── Workflows with schedules ── - List workflows = await dbContext.Workflows + List workflowSchedules = await dbContext.WorkflowSchedules .AsNoTracking( ) - .Include( w => w.Steps ) - .ThenInclude( s => s.Task ) - .Include( w => w.Steps ) - .ThenInclude( s => s.Dependencies ) - .Where( w => w.Enabled && w.ScheduleId != null ) + .Include( ws => ws.Workflow ) + .ThenInclude( w => w!.Steps ) + .ThenInclude( s => s.Task ) + .Include( ws => ws.Workflow ) + .ThenInclude( w => w!.Steps ) + .ThenInclude( s => s.Dependencies ) + .Where( ws => ws.Workflow!.Enabled ) .ToListAsync( context.CancellationToken ); - foreach (Workflow workflow in workflows) { + foreach (WorkflowSchedule ws in workflowSchedules) { + Workflow workflow = ws.Workflow!; // Check if any task in the workflow matches the agent's tags bool anyMatch = workflow.Steps.Any( step => step.Task is not null && @@ -103,7 +109,7 @@ step.Task is not null && continue; } - Schedule? schedule = await scheduleService.GetByIdAsync( workflow.ScheduleId!.Value, context.CancellationToken ); + Schedule? schedule = await scheduleService.GetByIdAsync( ws.ScheduleId, context.CancellationToken ); if (schedule is null) { continue; } @@ -200,6 +206,9 @@ private static ScheduleDefinition MapScheduleDefinition( Schedule schedule ) { def.HasHolidayCalendar = schedule.HolidayCalendar is not null; def.HolidayCalendarMode = schedule.HolidayCalendarMode?.ToString( ) ?? string.Empty; + // Catch-up flag + def.CatchUpEnabled = schedule.DbSchedule.CatchUpEnabled; + return def; } diff --git a/src/Werkr.Api/Services/WorkflowExecutionGrpcService.cs b/src/Werkr.Api/Services/WorkflowExecutionGrpcService.cs deleted file mode 100644 index 3943fb3..0000000 --- a/src/Werkr.Api/Services/WorkflowExecutionGrpcService.cs +++ /dev/null @@ -1,110 +0,0 @@ -using Grpc.Core; -using Microsoft.EntityFrameworkCore; -using Werkr.Common.Protos; -using Werkr.Core.Communication; -using Werkr.Core.Workflows; -using Werkr.Data; -using Werkr.Data.Entities.Registration; -using Werkr.Data.Entities.Workflows; - -namespace Werkr.Api.Services; - -/// -/// gRPC service for handling Agent-initiated workflow execution requests. -/// When an Agent's schedule triggers a workflow, the Agent delegates execution -/// back to the Server because only the Server can orchestrate multi-agent workflows. -/// All RPCs use . -/// -/// Database context. -/// Workflow executor for DAG orchestration. -/// Logger instance. -public sealed class WorkflowExecutionGrpcService( - WerkrDbContext dbContext, - WorkflowExecutor workflowExecutor, - ILogger logger -) : WorkflowExecution.WorkflowExecutionBase { - - /// - /// Handles an Agent's request to run a workflow. Loads the workflow - /// and delegates to . - /// - public override async Task RequestWorkflowRun( - EncryptedEnvelope request, - ServerCallContext context - ) { - - RegisteredConnection connection = GetConnection( context ); - string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); - - WorkflowRunGrpcRequest inner = PayloadEncryptor.DecryptFromEnvelope( - request, connection.SharedKey ); - - if (string.IsNullOrWhiteSpace( inner.ConnectionId )) { - throw new RpcException( new Status( StatusCode.InvalidArgument, "Connection ID is required." ) ); - } - - if (logger.IsEnabled( LogLevel.Information )) { - logger.LogInformation( - "Agent {AgentId} requesting workflow {WorkflowId} execution.", - inner.ConnectionId, inner.WorkflowId.ToString( ) ); - } - - Workflow? workflow = await dbContext.Workflows - .Include( w => w.Steps ) - .ThenInclude( s => s.Task ) - .Include( w => w.Steps ) - .ThenInclude( s => s.Dependencies ) - .FirstOrDefaultAsync( w => w.Id == inner.WorkflowId, context.CancellationToken ); - - if (workflow is null) { - WorkflowRunGrpcResponse notFoundResponse = new( ) { - Accepted = false, - Message = $"Workflow with Id={inner.WorkflowId} not found.", - }; - return PayloadEncryptor.EncryptToEnvelope( notFoundResponse, connection.SharedKey, keyId ); - } - - if (!workflow.Enabled) { - WorkflowRunGrpcResponse disabledResponse = new( ) { - Accepted = false, - Message = $"Workflow '{workflow.Name}' is disabled.", - }; - return PayloadEncryptor.EncryptToEnvelope( disabledResponse, connection.SharedKey, keyId ); - } - - try { - WorkflowRun run = await workflowExecutor.ExecuteAsync( workflow, context.CancellationToken ); - - if (logger.IsEnabled( LogLevel.Information )) { - logger.LogInformation( - "Workflow run {RunId} initiated for workflow {WorkflowId} '{WorkflowName}' by agent {AgentId}.", - run.Id.ToString( ), workflow.Id.ToString( ), workflow.Name, inner.ConnectionId ); - } - - WorkflowRunGrpcResponse response = new( ) { - Accepted = true, - WorkflowRunId = run.Id.ToString( ), - Message = $"Workflow run {run.Id} initiated.", - }; - return PayloadEncryptor.EncryptToEnvelope( response, connection.SharedKey, keyId ); - } catch (Exception ex) { - logger.LogError( ex, "Failed to execute workflow {WorkflowId} requested by agent {AgentId}.", - inner.WorkflowId.ToString( ), inner.ConnectionId ); - - WorkflowRunGrpcResponse errorResponse = new( ) { - Accepted = false, - Message = $"Failed to execute workflow: {ex.Message}", - }; - return PayloadEncryptor.EncryptToEnvelope( errorResponse, connection.SharedKey, keyId ); - } - } - - /// - /// Extracts the authenticated from the gRPC call context's user state. - /// - private static RegisteredConnection GetConnection( ServerCallContext context ) { - return context.UserState.TryGetValue( "Connection", out object? connObj ) && connObj is RegisteredConnection connection - ? connection - : throw new RpcException( new Status( StatusCode.Internal, "Connection not resolved by interceptor." ) ); - } -} diff --git a/src/Werkr.Api/Werkr.Api.csproj b/src/Werkr.Api/Werkr.Api.csproj index 597955c..cb74e3f 100644 --- a/src/Werkr.Api/Werkr.Api.csproj +++ b/src/Werkr.Api/Werkr.Api.csproj @@ -23,10 +23,10 @@ - + + and imported via ProjectReference. --> diff --git a/src/Werkr.AppHost/AppHost.cs b/src/Werkr.AppHost/AppHost.cs index 411466d..20bfe85 100644 --- a/src/Werkr.AppHost/AppHost.cs +++ b/src/Werkr.AppHost/AppHost.cs @@ -15,7 +15,7 @@ public static void Main( string[] args ) { IResourceBuilder werkrIdentityDb = postgres.AddDatabase( "werkridentitydb" ); // Agent - IResourceBuilder agent = builder.AddProject( "agent" ) + _ = builder.AddProject("agent") .WithHttpHealthCheck( "/health" ) .WaitFor( werkrDb ); @@ -29,11 +29,9 @@ public static void Main( string[] args ) { _ = builder.AddProject( "server" ) .WithExternalHttpEndpoints( ) .WithHttpHealthCheck( "/health" ) - .WithReference( apiService ) - .WithReference( agent ) + .WithReference(apiService) .WithReference( werkrIdentityDb ) - .WaitFor( apiService ) - .WaitFor( agent ) + .WaitFor(apiService) .WaitFor( werkrIdentityDb ); builder.Build( ).Run( ); diff --git a/src/Werkr.Common/Models/ExecuteCommandRequest.cs b/src/Werkr.Common/Models/ExecuteCommandRequest.cs index 409a98b..7af4d84 100644 --- a/src/Werkr.Common/Models/ExecuteCommandRequest.cs +++ b/src/Werkr.Common/Models/ExecuteCommandRequest.cs @@ -1,11 +1,13 @@ namespace Werkr.Common.Models; -/// Request DTO for the command execution endpoint. -/// The operator type string ("PowerShell" or "SystemShell"). -/// The plaintext command to execute. -/// Timeout in minutes. Defaults to 30. +/// Request body for the agent execute/shell command endpoint. +/// The shell command or script content to execute. +/// +/// Optional action type as an integer matching TaskActionType values: +/// 0 = PowerShellCommand, 1 = PowerShellScript, 2 = ShellCommand, 3 = ShellScript, 4 = Action. +/// Defaults to 2 (ShellCommand) when omitted. +/// public sealed record ExecuteCommandRequest( - string OperatorType, string Command, - int TimeoutMinutes = 30 + int? ActionType = null ); diff --git a/src/Werkr.Common/Models/TaskCreateRequest.cs b/src/Werkr.Common/Models/TaskCreateRequest.cs index 2928ac5..9f2856b 100644 --- a/src/Werkr.Common/Models/TaskCreateRequest.cs +++ b/src/Werkr.Common/Models/TaskCreateRequest.cs @@ -11,7 +11,6 @@ public sealed record TaskCreateRequest( bool Enabled = true, long? TimeoutMinutes = null, string? SuccessCriteria = null, - Guid? ScheduleId = null, long? WorkflowId = null, string? ActionSubType = null, string? ActionParameters = null diff --git a/src/Werkr.Common/Models/TaskDto.cs b/src/Werkr.Common/Models/TaskDto.cs index 86d0c61..65f56a9 100644 --- a/src/Werkr.Common/Models/TaskDto.cs +++ b/src/Werkr.Common/Models/TaskDto.cs @@ -14,7 +14,6 @@ public sealed record TaskDto( int SyncIntervalMinutes, string? SuccessCriteria, string EffectiveSuccessCriteria, - Guid? ScheduleId, long? WorkflowId, string? ActionSubType = null, string? ActionParameters = null diff --git a/src/Werkr.Common/Models/TaskUpdateRequest.cs b/src/Werkr.Common/Models/TaskUpdateRequest.cs index f2fa867..65b20e3 100644 --- a/src/Werkr.Common/Models/TaskUpdateRequest.cs +++ b/src/Werkr.Common/Models/TaskUpdateRequest.cs @@ -11,7 +11,6 @@ public sealed record TaskUpdateRequest( bool Enabled = true, long? TimeoutMinutes = null, string? SuccessCriteria = null, - Guid? ScheduleId = null, long? WorkflowId = null, string? ActionSubType = null, string? ActionParameters = null diff --git a/src/Werkr.Common/Models/WorkflowCreateRequest.cs b/src/Werkr.Common/Models/WorkflowCreateRequest.cs index f19fb5d..0700e93 100644 --- a/src/Werkr.Common/Models/WorkflowCreateRequest.cs +++ b/src/Werkr.Common/Models/WorkflowCreateRequest.cs @@ -4,6 +4,5 @@ namespace Werkr.Common.Models; public sealed record WorkflowCreateRequest( string Name, string? Description = null, - bool Enabled = true, - Guid? ScheduleId = null + bool Enabled = true ); diff --git a/src/Werkr.Common/Models/WorkflowDto.cs b/src/Werkr.Common/Models/WorkflowDto.cs index 4e7fd58..aa86238 100644 --- a/src/Werkr.Common/Models/WorkflowDto.cs +++ b/src/Werkr.Common/Models/WorkflowDto.cs @@ -6,6 +6,5 @@ public sealed record WorkflowDto( string Name, string Description, bool Enabled, - Guid? ScheduleId, IReadOnlyList Steps ); diff --git a/src/Werkr.Common/Models/WorkflowUpdateRequest.cs b/src/Werkr.Common/Models/WorkflowUpdateRequest.cs index fc81636..a90ce59 100644 --- a/src/Werkr.Common/Models/WorkflowUpdateRequest.cs +++ b/src/Werkr.Common/Models/WorkflowUpdateRequest.cs @@ -4,6 +4,5 @@ namespace Werkr.Common.Models; public sealed record WorkflowUpdateRequest( string Name, string? Description = null, - bool Enabled = true, - Guid? ScheduleId = null + bool Enabled = true ); diff --git a/src/Werkr.Common/Protos/JobReport.proto b/src/Werkr.Common/Protos/JobReport.proto index 78e71dd..4a5c011 100644 --- a/src/Werkr.Common/Protos/JobReport.proto +++ b/src/Werkr.Common/Protos/JobReport.proto @@ -36,6 +36,10 @@ message JobResultRequest { string workflow_run_id = 11; // Tail preview of the output (last N lines) for quick display without fetching the full log. string output_preview = 12; + // Agent-assigned job ID. When present, the server upserts using this ID. + string job_id = 13; + // Schedule ID that triggered this job execution. + string schedule_id = 14; } message JobResultResponse { diff --git a/src/Werkr.Common/Protos/OutputStreaming.proto b/src/Werkr.Common/Protos/OutputStreaming.proto new file mode 100644 index 0000000..4c0a957 --- /dev/null +++ b/src/Werkr.Common/Protos/OutputStreaming.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; +option csharp_namespace = "Werkr.Common.Protos"; +package werkr.common.protos; + +// Bidirectional stream between agent and server for real-time output delivery. +// Encryption is handled at the gRPC interceptor layer, not at proto level. +// Agent pushes output lines as tasks execute; server sends subscription requests. +service OutputStreamingService { + // Agent opens this stream on startup. Server sends subscription requests; + // agent sends output lines for subscribed tasks. + rpc StreamOutput (stream OutputMessage) returns (stream OutputSubscription); +} + +message OutputMessage { + int64 task_id = 1; + string schedule_id = 2; + string job_id = 3; + oneof payload { + OutputLine line = 4; + OutputComplete complete = 5; + } +} + +message OutputLine { + string text = 1; + string log_level = 2; + string timestamp = 3; +} + +message OutputComplete { + int32 exit_code = 1; + bool success = 2; + string error_message = 3; +} + +message OutputSubscription { + int64 task_id = 1; + string schedule_id = 2; + bool subscribe = 3; +} diff --git a/src/Werkr.Common/Protos/ScheduleSync.proto b/src/Werkr.Common/Protos/ScheduleSync.proto index 6f88ecf..254375e 100644 --- a/src/Werkr.Common/Protos/ScheduleSync.proto +++ b/src/Werkr.Common/Protos/ScheduleSync.proto @@ -56,6 +56,7 @@ message ScheduleDefinition { RepeatOptionsDef repeat = 12; bool has_holiday_calendar = 13; string holiday_calendar_mode = 14; + bool catch_up_enabled = 15; } message DailyRecurrenceDef { diff --git a/src/Werkr.Common/Protos/WorkflowExecution.proto b/src/Werkr.Common/Protos/WorkflowExecution.proto deleted file mode 100644 index 8d7f1ad..0000000 --- a/src/Werkr.Common/Protos/WorkflowExecution.proto +++ /dev/null @@ -1,28 +0,0 @@ -syntax = "proto3"; -option csharp_namespace = "Werkr.Common.Protos"; -package werkr.common.protos; - -import "EncryptedEnvelope.proto"; - -// Agent requests the server to orchestrate a workflow execution (API hosts this service). -// All RPCs use EncryptedEnvelope. Inner payload types defined below. -service WorkflowExecution { - // Request envelope contains WorkflowRunGrpcRequest; response contains WorkflowRunGrpcResponse. - rpc RequestWorkflowRun (EncryptedEnvelope) returns (EncryptedEnvelope); -} - -message WorkflowRunGrpcRequest { - // Agent's connection ID for authentication. - string connection_id = 1; - // The workflow ID to execute. - int64 workflow_id = 2; -} - -message WorkflowRunGrpcResponse { - // Whether the server accepted the workflow run request. - bool accepted = 1; - // The assigned workflow run ID. - string workflow_run_id = 2; - // Human-readable status message. - string message = 3; -} diff --git a/src/Werkr.Core/Communication/CommandDispatchFailure.cs b/src/Werkr.Core/Communication/CommandDispatchFailure.cs deleted file mode 100644 index 0f4187a..0000000 --- a/src/Werkr.Core/Communication/CommandDispatchFailure.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Werkr.Core.Communication; - -/// -/// Categorizes the reason a command dispatch failed. -/// -public enum CommandDispatchFailure { - /// The agent connection ID was not found in the database. - AgentNotFound, - - /// The agent connection exists but has been revoked. - AgentRevoked, - - /// The agent is unreachable (gRPC transport failure, DNS, timeout). - AgentUnreachable, - - /// TLS handshake or certificate validation failed. - TlsError, - - /// Payload encryption or decryption failed. - EncryptionError, - - /// An unknown or uncategorized error occurred. - Unknown -} diff --git a/src/Werkr.Core/Communication/CommandDispatcher.cs b/src/Werkr.Core/Communication/CommandDispatcher.cs deleted file mode 100644 index 8b17641..0000000 --- a/src/Werkr.Core/Communication/CommandDispatcher.cs +++ /dev/null @@ -1,438 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Security.Authentication; -using System.Security.Cryptography; -using System.Text.Json; - -using Grpc.Core; - -using Microsoft.Extensions.Logging; - -using Werkr.Agent.Protos; -using Werkr.Common.Models; -using Werkr.Common.Models.Actions; -using Werkr.Common.Protos; -using Werkr.Data.Entities.Registration; - -namespace Werkr.Core.Communication; - -/// -/// Orchestrates sending commands to Agents via gRPC and yielding decrypted output. -/// Single entry point for all command execution. All payloads are wrapped in -/// using the connection's SharedKey. -/// -/// Initializes a new instance of the class. -/// Agent connection manager for channel resolution. -/// Logger instance. -public sealed class CommandDispatcher( - AgentConnectionManager connectionManager, - ILogger logger -) : ICommandDispatcher { - - /// - /// Executes a command on the specified agent and yields decrypted output. - /// - /// The Server-side . - /// The operator type to use (PowerShell, SystemShell). - /// The plaintext command to execute. - /// Cancellation token for timeout/abort. - /// An async enumerable of decrypted records. - public async IAsyncEnumerable ExecuteCommandAsync( - Guid agentConnectionId, - OperatorType operatorType, - string command, - [EnumeratorCancellation] CancellationToken cancellationToken = default - ) { - - ( - Grpc.Net.Client.GrpcChannel channel, - RegisteredConnection connection - ) = - await ResolveChannelAsync( - agentConnectionId, - cancellationToken - ); - - string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); - EncryptedEnvelope envelope = EncryptRequest( - new ShellRequest { Command = command }, connection.SharedKey, keyId, agentConnectionId ); - - Guid callId = Guid.NewGuid( ); - CallOptions callOptions = AgentConnectionManager.CreateCallOptions( - connection, callId, cancellationToken ); - - if (logger.IsEnabled( LogLevel.Information )) { - logger.LogInformation( - "Dispatching {OperatorType} command to Agent {AgentId}, CallId {CallId}.", - operatorType.ToString( ), - agentConnectionId.ToString( ), - callId.ToString( ) - ); - } - - AsyncServerStreamingCall call; - - switch (operatorType) { - case OperatorType.PowerShell: - Pwsh.PwshClient pwshClient = new( channel ); - call = pwshClient.RunCommand( - envelope, - callOptions - ); - break; - - case OperatorType.SystemShell: - SystemShell.SystemShellClient shellClient = new( channel ); - call = shellClient.RunCommand( - envelope, - callOptions - ); - break; - - default: - yield return OperatorOutput.Create( - "Error", $"Unsupported operator type: {operatorType}" ); - yield break; - } - - using (call) { - IAsyncEnumerable stream = GrpcOutputReader.ReadAsync( - call.ResponseStream, connection.SharedKey, cancellationToken ); - - IAsyncEnumerator enumerator = stream.GetAsyncEnumerator( cancellationToken ); - try { - while (true) { - bool moved; - try { - moved = await enumerator.MoveNextAsync( ); - } catch (Exception ex) { - throw TranslateException( - ex, - agentConnectionId - ); - } - if (!moved) { - break; - } - - yield return enumerator.Current; - } - } finally { - await enumerator.DisposeAsync( ); - } - } - } - - /// - /// Executes a script on the specified agent and yields decrypted output. - /// - /// The Server-side . - /// The operator type to use (PowerShell, SystemShell). - /// The script path to execute. - /// Optional script arguments. - /// Cancellation token for timeout/abort. - /// An async enumerable of decrypted records. - public async IAsyncEnumerable ExecuteScriptAsync( - Guid agentConnectionId, - OperatorType operatorType, - string scriptPath, - IEnumerable? args, - [EnumeratorCancellation] CancellationToken cancellationToken = default - ) { - - ( - Grpc.Net.Client.GrpcChannel channel, - RegisteredConnection connection - ) = - await ResolveChannelAsync( - agentConnectionId, - cancellationToken - ); - - string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); - Guid callId = Guid.NewGuid( ); - CallOptions callOptions = AgentConnectionManager.CreateCallOptions( - connection, callId, cancellationToken ); - - if (logger.IsEnabled( LogLevel.Information )) { - logger.LogInformation( - "Dispatching {OperatorType} script to Agent {AgentId}, CallId {CallId}.", - operatorType.ToString( ), - agentConnectionId.ToString( ), - callId.ToString( ) - ); - } - - AsyncServerStreamingCall call; - - if (args is not null) { - ScriptRequest innerRequest = new( ) { Script = scriptPath }; - innerRequest.Args.AddRange( args ); - - EncryptedEnvelope envelope = EncryptRequest( - innerRequest, connection.SharedKey, keyId, agentConnectionId ); - - switch (operatorType) { - case OperatorType.PowerShell: - call = new Pwsh.PwshClient( channel ) - .RunScriptWithArgs( - envelope, - callOptions - ); - break; - case OperatorType.SystemShell: - call = new SystemShell.SystemShellClient( channel ) - .RunScriptWithArgs( - envelope, - callOptions - ); - break; - default: - yield return OperatorOutput.Create( - "Error", $"Unsupported operator type: {operatorType}" ); - yield break; - } - } else { - EncryptedEnvelope envelope = EncryptRequest( - new ShellRequest { Command = scriptPath }, connection.SharedKey, keyId, agentConnectionId ); - - switch (operatorType) { - case OperatorType.PowerShell: - call = new Pwsh.PwshClient( channel ) - .RunScript( - envelope, - callOptions - ); - break; - case OperatorType.SystemShell: - call = new SystemShell.SystemShellClient( channel ) - .RunScript( - envelope, - callOptions - ); - break; - default: - yield return OperatorOutput.Create( - "Error", $"Unsupported operator type: {operatorType}" ); - yield break; - } - } - - using (call) { - IAsyncEnumerable stream = GrpcOutputReader.ReadAsync( - call.ResponseStream, connection.SharedKey, cancellationToken ); - - IAsyncEnumerator enumerator = stream.GetAsyncEnumerator( cancellationToken ); - try { - while (true) { - bool moved; - try { - moved = await enumerator.MoveNextAsync( ); - } catch (Exception ex) { - throw TranslateException( - ex, - agentConnectionId - ); - } - if (!moved) { - break; - } - - yield return enumerator.Current; - } - } finally { - await enumerator.DisposeAsync( ); - } - } - } - - /// - /// Executes a built-in action on the specified agent and yields decrypted output. - /// - /// The Server-side . - /// The action descriptor containing action name and JSON parameters. - /// Cancellation token for timeout/abort. - /// An async enumerable of decrypted records. - public async IAsyncEnumerable ExecuteActionAsync( - Guid agentConnectionId, - ActionDescriptor descriptor, - [EnumeratorCancellation] CancellationToken cancellationToken = default - ) { - - ( - Grpc.Net.Client.GrpcChannel channel, - RegisteredConnection connection - ) = - await ResolveChannelAsync( - agentConnectionId, - cancellationToken - ); - - string keyId = connection.ActiveKeyId ?? connection.Id.ToString( ); - - ActionRequest actionRequest = new( ) { - ActionName = descriptor.Action, - ParametersJson = JsonSerializer.Serialize( descriptor.Parameters ), - }; - - EncryptedEnvelope envelope = EncryptRequest( - actionRequest, connection.SharedKey, keyId, agentConnectionId ); - - Guid callId = Guid.NewGuid( ); - CallOptions callOptions = AgentConnectionManager.CreateCallOptions( - connection, callId, cancellationToken ); - - if (logger.IsEnabled( LogLevel.Information )) { - logger.LogInformation( - "Dispatching Action '{ActionName}' to Agent {AgentId}, CallId {CallId}.", - descriptor.Action, - agentConnectionId.ToString( ), - callId.ToString( ) - ); - } - - Werkr.Agent.Protos.Action.ActionClient actionClient = new( channel ); - using AsyncServerStreamingCall call = actionClient.RunAction( - envelope, - callOptions - ); - - IAsyncEnumerable stream = GrpcOutputReader.ReadAsync( - call.ResponseStream, connection.SharedKey, cancellationToken ); - - IAsyncEnumerator enumerator = stream.GetAsyncEnumerator( cancellationToken ); - try { - while (true) { - bool moved; - try { - moved = await enumerator.MoveNextAsync( ); - } catch (Exception ex) { - throw TranslateException( - ex, - agentConnectionId - ); - } - if (!moved) { - break; - } - - yield return enumerator.Current; - } - } finally { - await enumerator.DisposeAsync( ); - } - } - - /// - /// Resolves the gRPC channel for the agent, translating raw exceptions - /// into . - /// - private async Task<(Grpc.Net.Client.GrpcChannel Channel, RegisteredConnection Connection)> - ResolveChannelAsync( - Guid agentConnectionId, - CancellationToken cancellationToken - ) { - try { - return await connectionManager.GetChannelAsync( - agentConnectionId, - cancellationToken - ); - } catch (InvalidOperationException ex) when ( - ex.Message.Contains( - "not found", - StringComparison.OrdinalIgnoreCase - )) { - throw new CommandDispatcherException( - CommandDispatchFailure.AgentNotFound, - $"Agent connection '{agentConnectionId}' was not found.", - agentConnectionId, - ex - ); - } catch (InvalidOperationException ex) when ( - ex.Message.Contains( - "revoked", - StringComparison.OrdinalIgnoreCase - )) { - throw new CommandDispatcherException( - CommandDispatchFailure.AgentRevoked, - $"Agent connection '{agentConnectionId}' has been revoked.", - agentConnectionId, - ex - ); - } catch (InvalidOperationException ex) { - throw new CommandDispatcherException( - CommandDispatchFailure.AgentNotFound, - ex.Message, - agentConnectionId, - ex - ); - } - } - - /// - /// Encrypts a protobuf request into an , - /// wrapping cryptographic failures into a typed exception. - /// - private static EncryptedEnvelope EncryptRequest( - T message, byte[] sharedKey, string keyId, Guid agentConnectionId ) - where T : Google.Protobuf.IMessage { - try { - return PayloadEncryptor.EncryptToEnvelope( - message, - sharedKey, - keyId - ); - } catch (CryptographicException ex) { - throw new CommandDispatcherException( - CommandDispatchFailure.EncryptionError, - "Failed to encrypt the command payload.", - agentConnectionId, - ex - ); - } - } - - /// - /// Translates raw exceptions into . - /// - private static CommandDispatcherException TranslateException( - Exception ex, - Guid agentConnectionId - ) => - ex switch { - CommandDispatcherException cde => cde, - RpcException rpc when rpc.StatusCode == StatusCode.Unavailable => - new CommandDispatcherException( - CommandDispatchFailure.AgentUnreachable, - $"Agent '{agentConnectionId}' is unreachable.", - agentConnectionId, - rpc - ), - RpcException rpc => - new CommandDispatcherException( - CommandDispatchFailure.AgentUnreachable, - $"gRPC error communicating with agent '{agentConnectionId}': {rpc.Status.Detail}", - agentConnectionId, - rpc - ), - AuthenticationException auth => - new CommandDispatcherException( - CommandDispatchFailure.TlsError, - "TLS authentication failed while connecting to the agent.", - agentConnectionId, - auth - ), - CryptographicException crypto => - new CommandDispatcherException( - CommandDispatchFailure.EncryptionError, - "Payload decryption failed.", - agentConnectionId, - crypto - ), - _ => new CommandDispatcherException( - CommandDispatchFailure.Unknown, - ex.Message, - agentConnectionId, - ex - ) - }; -} diff --git a/src/Werkr.Core/Communication/CommandDispatcherException.cs b/src/Werkr.Core/Communication/CommandDispatcherException.cs deleted file mode 100644 index e4eb8d4..0000000 --- a/src/Werkr.Core/Communication/CommandDispatcherException.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace Werkr.Core.Communication; - -/// -/// Typed exception thrown by to provide -/// actionable error context to callers without leaking raw internal details. -/// -public sealed class CommandDispatcherException : Exception { - /// The categorized failure reason. - public CommandDispatchFailure Reason { get; } - - /// The agent connection ID the dispatch targeted, if known. - public Guid? AgentConnectionId { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The categorized failure reason. - /// The internal error message (for logging). - /// The agent connection ID, if known. - /// The original exception, if any. - public CommandDispatcherException( - CommandDispatchFailure reason, - string message, - Guid? agentConnectionId = null, - Exception? innerException = null - ) - : base( - message, - innerException - ) { - Reason = reason; - AgentConnectionId = agentConnectionId; - } - - /// - /// Returns a user-safe description of the failure that avoids exposing internal details. - /// - public string UserMessage => Reason switch { - CommandDispatchFailure.AgentNotFound => - "The specified agent was not found. It may have been removed.", - CommandDispatchFailure.AgentRevoked => - "The agent connection has been revoked and can no longer accept commands.", - CommandDispatchFailure.AgentUnreachable => - "The agent is unreachable. Verify that it is running and network connectivity is intact.", - CommandDispatchFailure.TlsError => - "A TLS/certificate error occurred communicating with the agent. Check certificate configuration.", - CommandDispatchFailure.EncryptionError => - "Failed to encrypt or decrypt the command payload. The shared key may be invalid.", - _ => - "An unexpected error occurred while dispatching the command." - }; -} diff --git a/src/Werkr.Core/Communication/GrpcOutputReader.cs b/src/Werkr.Core/Communication/GrpcOutputReader.cs deleted file mode 100644 index a913a86..0000000 --- a/src/Werkr.Core/Communication/GrpcOutputReader.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Runtime.CompilerServices; - -using Grpc.Core; - -using Werkr.Agent.Protos; -using Werkr.Common.Protos; - -namespace Werkr.Core.Communication; - -/// -/// Reads a gRPC server-streaming response of messages, -/// decrypts each envelope to a , and yields -/// records. -/// -public static class GrpcOutputReader { - /// - /// Reads encrypted messages from the response stream, - /// decrypts them to using the connection's SharedKey, - /// and yields . - /// - /// The gRPC response stream reader. - /// The AES-256 shared key for payload decryption. Must not be null. - /// Cancellation token. - /// An async enumerable of decrypted records. - /// Thrown when is null. - public static async IAsyncEnumerable ReadAsync( - IAsyncStreamReader responseStream, - byte[] sharedKey, - [EnumeratorCancellation] CancellationToken cancellationToken = default - ) { - - ArgumentNullException.ThrowIfNull( - sharedKey, - nameof( sharedKey ) - ); - - await foreach (EncryptedEnvelope envelope in responseStream.ReadAllAsync( cancellationToken )) { - GrpcLogMsg msg = PayloadEncryptor.DecryptFromEnvelope( - envelope, - sharedKey - ); - yield return new OperatorOutput( - msg.LogLevel, - msg.Message, - msg.Timestamp - ); - } - } -} diff --git a/src/Werkr.Core/Communication/ICommandDispatcher.cs b/src/Werkr.Core/Communication/ICommandDispatcher.cs deleted file mode 100644 index 0fc003a..0000000 --- a/src/Werkr.Core/Communication/ICommandDispatcher.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Werkr.Common.Models; -using Werkr.Common.Models.Actions; - -namespace Werkr.Core.Communication; - -/// -/// Dispatches commands and scripts to registered Agents and streams output. -/// -public interface ICommandDispatcher { - /// - /// Executes a command on the specified agent and yields output. - /// - /// The Server-side agent connection id. - /// The operator type to execute with. - /// The command to execute. - /// Cancellation token for timeout or cancellation. - /// Streamed command output. - IAsyncEnumerable ExecuteCommandAsync( - Guid agentConnectionId, - OperatorType operatorType, - string command, - CancellationToken cancellationToken = default - ); - - /// - /// Executes a script on the specified agent and yields output. - /// - /// The Server-side agent connection id. - /// The operator type to execute with. - /// The script path to execute. - /// Optional script arguments. - /// Cancellation token for timeout or cancellation. - /// Streamed script output. - IAsyncEnumerable ExecuteScriptAsync( - Guid agentConnectionId, - OperatorType operatorType, - string scriptPath, - IEnumerable? args, - CancellationToken cancellationToken = default - ); - - /// - /// Executes a built-in action on the specified agent and yields output. - /// - /// The Server-side agent connection id. - /// The action descriptor containing action name and parameters. - /// Cancellation token for timeout or cancellation. - /// Streamed action output. - IAsyncEnumerable ExecuteActionAsync( - Guid agentConnectionId, - ActionDescriptor descriptor, - CancellationToken cancellationToken = default - ); -} diff --git a/src/Werkr.Core/Communication/JobEvent.cs b/src/Werkr.Core/Communication/JobEvent.cs new file mode 100644 index 0000000..8d0fe9c --- /dev/null +++ b/src/Werkr.Core/Communication/JobEvent.cs @@ -0,0 +1,24 @@ +namespace Werkr.Core.Communication; + +/// +/// Represents a job completion event broadcast to SSE subscribers. +/// Published by when an agent reports a job result. +/// +/// The persisted job's unique identifier. +/// The task that was executed. +/// Optional workflow run this job belongs to (null for standalone tasks). +/// Whether the job completed successfully. +/// Process exit code, if available. +/// Total job runtime in seconds. +/// The agent that executed the job. +/// UTC timestamp when the event was created. +public sealed record JobEvent( + Guid JobId, + long TaskId, + Guid? WorkflowRunId, + bool Success, + int? ExitCode, + double RuntimeSeconds, + Guid AgentConnectionId, + DateTime Timestamp +); diff --git a/src/Werkr.Core/Communication/JobEventBroadcaster.cs b/src/Werkr.Core/Communication/JobEventBroadcaster.cs new file mode 100644 index 0000000..e07e577 --- /dev/null +++ b/src/Werkr.Core/Communication/JobEventBroadcaster.cs @@ -0,0 +1,90 @@ +using System.Threading.Channels; +using Microsoft.Extensions.Logging; + +namespace Werkr.Core.Communication; + +/// +/// Singleton broadcaster that fans out notifications to all +/// active SSE subscribers. Each subscriber receives its own bounded +/// so slow consumers cannot block producers. +/// +public sealed partial class JobEventBroadcaster { + + private readonly Lock _lock = new( ); + private readonly List> _subscribers = []; + private readonly ILogger _logger; + + /// Initializes a new broadcaster. + /// Logger instance. + public JobEventBroadcaster( ILogger logger ) { + _logger = logger; + } + + /// + /// Creates a new subscription. The caller reads from the returned + /// and must call + /// (or dispose the ) when done. + /// + /// A subscription handle containing the reader and unsubscribe action. + public JobEventSubscription Subscribe( ) { + Channel channel = Channel.CreateBounded( + new BoundedChannelOptions( 256 ) { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + } ); + + lock (_lock) { + _subscribers.Add( channel.Writer ); + } + + LogSubscribed( _subscribers.Count ); + + return new JobEventSubscription( channel.Reader, ( ) => Unsubscribe( channel.Writer ) ); + } + + /// + /// Publishes a to every active subscriber. + /// Non-blocking — events are dropped for subscribers whose channel is full. + /// + /// The event to broadcast. + public void Publish( JobEvent jobEvent ) { + ChannelWriter[] snapshot; + lock (_lock) { + snapshot = [.. _subscribers]; + } + + int delivered = 0; + foreach (ChannelWriter writer in snapshot) { + if (writer.TryWrite( jobEvent )) { + delivered++; + } + } + + LogPublished( jobEvent.JobId, delivered, snapshot.Length ); + } + + /// + /// Removes a subscriber's writer from the broadcast list and completes it. + /// + private void Unsubscribe( ChannelWriter writer ) { + lock (_lock) { + _ = _subscribers.Remove( writer ); + } + + _ = writer.TryComplete( ); + LogUnsubscribed( _subscribers.Count ); + } + + [LoggerMessage( Level = LogLevel.Debug, + Message = "SSE subscriber added. Active subscribers: {Count}." )] + private partial void LogSubscribed( int count ); + + [LoggerMessage( Level = LogLevel.Debug, + Message = "Published JobEvent {JobId} to {Delivered}/{Total} subscribers." )] + private partial void LogPublished( Guid jobId, int delivered, int total ); + + [LoggerMessage( Level = LogLevel.Debug, + Message = "SSE subscriber removed. Active subscribers: {Count}." )] + private partial void LogUnsubscribed( int count ); +} diff --git a/src/Werkr.Core/Communication/JobEventSubscription.cs b/src/Werkr.Core/Communication/JobEventSubscription.cs new file mode 100644 index 0000000..bc044e6 --- /dev/null +++ b/src/Werkr.Core/Communication/JobEventSubscription.cs @@ -0,0 +1,34 @@ +using System.Threading.Channels; + +namespace Werkr.Core.Communication; + +/// +/// Represents an active SSE subscription to job events. +/// Disposing or calling removes the subscriber from the broadcaster. +/// +public sealed class JobEventSubscription : IDisposable { + + private readonly Action _unsubscribe; + private bool _disposed; + + /// The channel reader that delivers job events to this subscriber. + public ChannelReader Reader { get; } + + /// Initializes a new subscription. + /// Channel reader for this subscriber. + /// Callback to remove the subscriber from the broadcaster. + public JobEventSubscription( ChannelReader reader, Action unsubscribe ) { + Reader = reader; + _unsubscribe = unsubscribe; + } + + /// Unsubscribes from the broadcaster. + public void Dispose( ) { + if (_disposed) { + return; + } + + _disposed = true; + _unsubscribe( ); + } +} diff --git a/src/Werkr.Core/Scheduling/RunNowService.cs b/src/Werkr.Core/Scheduling/RunNowService.cs new file mode 100644 index 0000000..6f0fb29 --- /dev/null +++ b/src/Werkr.Core/Scheduling/RunNowService.cs @@ -0,0 +1,171 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Werkr.Data; +using Werkr.Data.Entities.Schedule; +using Werkr.Data.Entities.Tasks; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Core.Scheduling; + +/// +/// Creates one-time "Run Now" schedules that fire immediately, enabling +/// ad-hoc task and workflow execution through the existing schedule engine. +/// +/// Database context. +/// Logger. +public sealed partial class RunNowService( + WerkrDbContext dbContext, + ILogger logger +) { + + /// + /// Creates a one-time schedule linked to the specified task and returns the schedule ID. + /// The schedule fires immediately; the agent picks it up on the next sync/invalidation cycle. + /// + /// The ID of the task to run. + /// Cancellation token. + /// The ID of the newly created one-time schedule. + /// Thrown when the task does not exist. + public async Task CreateTaskRunNowAsync( long taskId, CancellationToken ct = default ) { + WerkrTask task = await dbContext.Tasks + .FirstOrDefaultAsync( t => t.Id == taskId, ct ) + ?? throw new KeyNotFoundException( $"Task {taskId} not found." ); + + DbSchedule schedule = CreateRunNowSchedule( $"Run Now – {task.Name}" ); + _ = dbContext.Schedules.Add( schedule ); + _ = await dbContext.SaveChangesAsync( ct ); + + StartDateTimeInfo startDt = CreateStartDateTimeNow( schedule.Id ); + _ = dbContext.StartDateTimeInfos.Add( startDt ); + + TaskSchedule link = new( ) { + TaskId = taskId, + ScheduleId = schedule.Id, + IsOneTime = true, + CreatedAtUtc = DateTime.UtcNow, + }; + _ = dbContext.TaskSchedules.Add( link ); + _ = await dbContext.SaveChangesAsync( ct ); + + LogRunNowCreated( logger, "task", taskId, schedule.Id ); + return schedule.Id; + } + + /// + /// Creates a one-time schedule linked to the specified workflow and returns the schedule ID. + /// The schedule fires immediately; the agent picks it up on the next sync/invalidation cycle. + /// + /// The ID of the workflow to run. + /// Cancellation token. + /// The ID of the newly created one-time schedule. + /// Thrown when the workflow does not exist. + public async Task CreateWorkflowRunNowAsync( long workflowId, CancellationToken ct = default ) { + Workflow workflow = await dbContext.Set( ) + .FirstOrDefaultAsync( w => w.Id == workflowId, ct ) + ?? throw new KeyNotFoundException( $"Workflow {workflowId} not found." ); + + DbSchedule schedule = CreateRunNowSchedule( $"Run Now – {workflow.Name}" ); + _ = dbContext.Schedules.Add( schedule ); + _ = await dbContext.SaveChangesAsync( ct ); + + StartDateTimeInfo startDt = CreateStartDateTimeNow( schedule.Id ); + _ = dbContext.StartDateTimeInfos.Add( startDt ); + + WorkflowSchedule link = new( ) { + WorkflowId = workflowId, + ScheduleId = schedule.Id, + IsOneTime = true, + CreatedAtUtc = DateTime.UtcNow, + }; + _ = dbContext.WorkflowSchedules.Add( link ); + _ = await dbContext.SaveChangesAsync( ct ); + + LogRunNowCreated( logger, "workflow", workflowId, schedule.Id ); + return schedule.Id; + } + + /// + /// Creates an ephemeral task, links it to a one-time schedule, and returns + /// both the task ID and schedule ID. Ephemeral tasks are invisible in + /// the normal task list and are intended for single ad-hoc executions + /// (e.g. console shell commands). + /// + /// The shell command or script content to execute. + /// The action type (e.g. ). + /// Optional target tags used to route the task to specific agents. + /// Cancellation token. + /// A tuple of the new task ID and schedule ID. + public async Task<(long TaskId, Guid ScheduleId)> CreateEphemeralTaskAsync( + string command, + TaskActionType actionType, + string[]? targetTags = null, + CancellationToken ct = default + ) { + WerkrTask task = new( ) { + Name = $"Ephemeral – {DateTime.UtcNow:u}", + Description = "Ephemeral task created for ad-hoc execution.", + ActionType = actionType, + Content = command, + Enabled = true, + IsEphemeral = true, + TargetTags = targetTags ?? [], + SyncIntervalMinutes = 60, + TimeoutMinutes = 60, + }; + _ = dbContext.Tasks.Add( task ); + _ = await dbContext.SaveChangesAsync( ct ); + + DbSchedule schedule = CreateRunNowSchedule( $"Ephemeral – task {task.Id}" ); + _ = dbContext.Schedules.Add( schedule ); + _ = await dbContext.SaveChangesAsync( ct ); + + StartDateTimeInfo startDt = CreateStartDateTimeNow( schedule.Id ); + _ = dbContext.StartDateTimeInfos.Add( startDt ); + + TaskSchedule link = new( ) { + TaskId = task.Id, + ScheduleId = schedule.Id, + IsOneTime = true, + CreatedAtUtc = DateTime.UtcNow, + }; + _ = dbContext.TaskSchedules.Add( link ); + _ = await dbContext.SaveChangesAsync( ct ); + + LogEphemeralCreated( logger, task.Id, schedule.Id ); + return (task.Id, schedule.Id); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + /// + /// Builds a minimal configured for immediate one-time execution. + /// + private static DbSchedule CreateRunNowSchedule( string name ) => new( ) { + Name = name, + StopTaskAfterMinutes = 60, + CatchUpEnabled = true, + }; + + /// + /// Builds a pointing to "now" in UTC. + /// + private static StartDateTimeInfo CreateStartDateTimeNow( Guid scheduleId ) { + DateTime utcNow = DateTime.UtcNow; + return new StartDateTimeInfo { + ScheduleId = scheduleId, + Date = DateOnly.FromDateTime( utcNow ), + Time = TimeOnly.FromDateTime( utcNow ), + TimeZone = TimeZoneInfo.Utc, + }; + } + + [LoggerMessage( Level = LogLevel.Information, + Message = "Created Run-Now schedule {ScheduleId} for {EntityType} {EntityId}." )] + private static partial void LogRunNowCreated( + ILogger logger, string entityType, long entityId, Guid scheduleId ); + + [LoggerMessage( Level = LogLevel.Information, + Message = "Created ephemeral task {TaskId} with schedule {ScheduleId}." )] + private static partial void LogEphemeralCreated( + ILogger logger, long taskId, Guid scheduleId ); +} diff --git a/src/Werkr.Core/Tasks/JobExecutionService.cs b/src/Werkr.Core/Tasks/JobExecutionService.cs index 0809434..50d49a5 100644 --- a/src/Werkr.Core/Tasks/JobExecutionService.cs +++ b/src/Werkr.Core/Tasks/JobExecutionService.cs @@ -1,242 +1,25 @@ -using System.Diagnostics; -using System.Text.Json; - using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -using Werkr.Common.Models; -using Werkr.Common.Models.Actions; -using Werkr.Core.Communication; using Werkr.Data; -using Werkr.Data.Entities.Registration; using Werkr.Data.Entities.Tasks; namespace Werkr.Core.Tasks; /// -/// Orchestrates the execution of a as a . -/// Creates the job record, dispatches to the resolved agent, captures output, -/// evaluates success criteria, and finalizes the job record. +/// Provides job query and history operations. +/// +/// Task execution is now handled exclusively by the agent-side +/// ScheduleEvaluatorService and WorkflowExecutionService. +/// This service retains only the read-side operations (job history, +/// recent jobs, output retrieval) consumed by the API endpoints. +/// /// /// Database context. -/// Command dispatcher for sending commands to agents. -/// Resolves agents by tag matching. /// Writes job output to disk. -/// Evaluates success criteria against results. -/// Logger instance. public sealed class JobExecutionService( WerkrDbContext dbContext, - ICommandDispatcher commandDispatcher, - AgentResolver agentResolver, - JobOutputWriter outputWriter, - SuccessCriteriaEvaluator criteriaEvaluator, - ILogger logger + JobOutputWriter outputWriter ) { - /// - /// Executes a task by ID: resolves an agent, creates a job record, - /// dispatches the command/script, captures output, and evaluates success. - /// - /// The task identifier to execute. - /// Cancellation token. - /// The finalized record. - /// Task not found. - /// No matching agent available. - public async Task ExecuteAsync( - long taskId, - CancellationToken ct = default - ) { - // Load the task - WerkrTask task = await dbContext.Tasks.AsNoTracking( ).FirstOrDefaultAsync( - t => t.Id == taskId, - ct - ) - ?? throw new KeyNotFoundException( $"Task with Id={taskId} was not found." ); - - return await ExecuteAsync( - task, - ct - ); - } - - /// - /// Executes a task: resolves an agent, creates a job record, - /// dispatches the command/script, captures output, and evaluates success. - /// - /// The task to execute. - /// Cancellation token. - /// The finalized record. - /// No matching agent available. - public async Task ExecuteAsync( - WerkrTask task, - CancellationToken ct = default - ) { - // Resolve agent - RegisteredConnection agent = await agentResolver.ResolveAsync( - task.TargetTags, - ct - ) - ?? throw new InvalidOperationException( - $"No connected agent found matching tags [{string.Join( ", ", task.TargetTags )}]." ); - - return await ExecuteOnAgentAsync( - task, - agent, - workflowRunId: null, - ct - ); - } - - /// - /// Executes a task on a specific pre-resolved agent. Creates a job record, - /// dispatches the command/script, captures output, and evaluates success. - /// Used by after agent resolution, - /// and by WorkflowExecutor which resolves agents per-step. - /// - /// The task to execute. - /// The pre-resolved agent connection to execute on. - /// Optional workflow run ID to associate the job with. - /// Cancellation token. - /// The finalized record. - internal async Task ExecuteOnAgentAsync( - WerkrTask task, - RegisteredConnection agent, - Guid? workflowRunId, - CancellationToken ct = default - ) { - // Create the job record immediately (in-flight visibility) - WerkrJob job = new( ) { - TaskId = task.Id, - TaskSnapshot = task.Content, - StartTime = DateTime.UtcNow, - AgentConnectionId = agent.Id, - WorkflowRunId = workflowRunId, - OutputPath = $"{Guid.Empty}.log", - // placeholder until Id is generated - }; - _ = dbContext.Jobs.Add( job ); - _ = await dbContext.SaveChangesAsync( ct ); - - // Update output path with actual job Id - job.OutputPath = $"{job.Id}.log"; - - if (logger.IsEnabled( LogLevel.Information )) { - logger.LogInformation( - "Executing task {TaskId} '{TaskName}' as job {JobId} on agent {AgentId}.", - task.Id.ToString( ), - task.Name, - job.Id.ToString( ), - agent.Id.ToString( ) - ); - } - - // Set up timeout - int timeoutMinutes = (int) ( task.TimeoutMinutes ?? 30 ); - using CancellationTokenSource timeoutCts = new( TimeSpan.FromMinutes( timeoutMinutes ) ); - using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - ct, - timeoutCts.Token - ); - - Stopwatch stopwatch = Stopwatch.StartNew( ); - List collectedOutput = []; - int? exitCode = null; - Exception? executionException = null; - ErrorCategory errorCategory = ErrorCategory.None; - - try { - // Map TaskActionType to OperatorType and dispatch - IAsyncEnumerable outputStream = DispatchTask( - task, - agent.Id, - linkedCts.Token - ); - - // Consume output stream, writing each line to disk incrementally - await foreach (OperatorOutput output in outputStream.WithCancellation( linkedCts.Token )) { - collectedOutput.Add( output ); - await outputWriter.WriteLineAsync( - job.Id, - output, - CancellationToken.None - ); - } - - // Try to extract exit code from output (convention: last line with "ExitCode: N") - exitCode = ExtractExitCode( collectedOutput ); - } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { - errorCategory = ErrorCategory.Timeout; - executionException = new TimeoutException( - $"Job {job.Id} timed out after {timeoutMinutes} minutes." ); - logger.LogWarning( - "Job {JobId} timed out after {Timeout} minutes.", - job.Id.ToString( ), - timeoutMinutes.ToString( ) - ); - } catch (CommandDispatcherException cde) { - errorCategory = MapDispatchFailure( cde.Reason ); - executionException = cde; - logger.LogError( - cde, - "Job {JobId} dispatch failed: {Reason}.", - job.Id.ToString( ), - cde.Reason.ToString( ) - ); - } catch (OperationCanceledException ex) when (ct.IsCancellationRequested) { - errorCategory = ErrorCategory.Unknown; - executionException = ex; - logger.LogWarning( - "Job {JobId} was cancelled.", - job.Id.ToString( ) - ); - } catch (Exception ex) { - errorCategory = ErrorCategory.ScriptError; - executionException = ex; - logger.LogError( - ex, - "Job {JobId} failed with unexpected error.", - job.Id.ToString( ) - ); - } - - stopwatch.Stop( ); - - // Evaluate success - bool success = criteriaEvaluator.Evaluate( - task.ActionType, task.SuccessCriteria, exitCode, collectedOutput, executionException ); - - // Get tail preview - string? tailPreview = await outputWriter.GetTailPreviewAsync( - job.Id, - CancellationToken.None - ); - - // Finalize the job record - job.EndTime = DateTime.UtcNow; - job.RuntimeSeconds = stopwatch.Elapsed.TotalSeconds; - job.Success = success; - job.ExitCode = exitCode; - job.ErrorCategory = errorCategory; - job.Output = tailPreview; - - _ = await dbContext.SaveChangesAsync( CancellationToken.None ); - - if (logger.IsEnabled( LogLevel.Information )) { - logger.LogInformation( - "Job {JobId} completed: Success={Success}, " + - "ExitCode={ExitCode}, Runtime={Runtime:F1}s, " + - "ErrorCategory={ErrorCategory}.", - job.Id.ToString( ), - success.ToString( ), - exitCode?.ToString( ) ?? "null", - stopwatch.Elapsed.TotalSeconds, - errorCategory.ToString( ) - ); - } - - return job; - } - /// /// Retrieves job history for a task, ordered by most recent first. /// @@ -326,128 +109,4 @@ await outputWriter.ReadFullOutputAsync( jobId, ct ); - - /// - /// Maps a to an - /// and dispatches the appropriate command or script. - /// - private IAsyncEnumerable DispatchTask( - WerkrTask task, Guid agentConnectionId, CancellationToken ct ) { - - return task.ActionType switch { - TaskActionType.PowerShellCommand => - commandDispatcher.ExecuteCommandAsync( - agentConnectionId, - OperatorType.PowerShell, - task.Content, - ct - ), - - TaskActionType.PowerShellScript => - task.Arguments is { Length: > 0 } - ? commandDispatcher.ExecuteScriptAsync( - agentConnectionId, - OperatorType.PowerShell, - task.Content, - task.Arguments, - ct - ) - : commandDispatcher.ExecuteScriptAsync( - agentConnectionId, - OperatorType.PowerShell, - task.Content, - null, - ct - ), - - TaskActionType.ShellCommand => - commandDispatcher.ExecuteCommandAsync( - agentConnectionId, - OperatorType.SystemShell, - task.Content, - ct - ), - - TaskActionType.ShellScript => - task.Arguments is { Length: > 0 } - ? commandDispatcher.ExecuteScriptAsync( - agentConnectionId, - OperatorType.SystemShell, - task.Content, - task.Arguments, - ct - ) - : commandDispatcher.ExecuteScriptAsync( - agentConnectionId, - OperatorType.SystemShell, - task.Content, - null, - ct - ), - - TaskActionType.Action => - commandDispatcher.ExecuteActionAsync( agentConnectionId, - CreateActionDescriptor( task ), - ct - ), - - _ => throw new InvalidOperationException( $"Unsupported action type: {task.ActionType}." ) - }; - } - - private static ActionDescriptor CreateActionDescriptor( WerkrTask task ) { - using JsonDocument parsedParameters = JsonDocument.Parse( task.ActionParameters ?? "{}" ); - return new ActionDescriptor { - Action = task.ActionSubType - ?? throw new InvalidOperationException( - "ActionSubType is required for Action tasks." - ), - Parameters = parsedParameters.RootElement.Clone( ), - }; - } - - /// - /// Maps to . - /// - private static ErrorCategory MapDispatchFailure( CommandDispatchFailure failure ) => - failure switch { - CommandDispatchFailure.AgentNotFound => ErrorCategory.AgentUnreachable, - CommandDispatchFailure.AgentRevoked => ErrorCategory.AgentUnreachable, - CommandDispatchFailure.AgentUnreachable => ErrorCategory.AgentUnreachable, - CommandDispatchFailure.TlsError => ErrorCategory.AgentUnreachable, - CommandDispatchFailure.EncryptionError => ErrorCategory.Unknown, - _ => ErrorCategory.Unknown - }; - - /// - /// Attempts to extract an exit code from the output stream. - /// Looks for the last output line that matches the convention used by - /// the operators: an Information-level line ending with exited with code N. - /// - private static int? ExtractExitCode( List output ) { - // Look for exit code in reverse order (most likely in last few lines) - for (int i = output.Count - 1; i >= Math.Max( - 0, - output.Count - 10 - ); i--) { - string message = output[i].Message; - - // Pattern: "Process exited with code 0" or "exited with code 123" - int idx = message.LastIndexOf( - "exited with code ", - StringComparison.OrdinalIgnoreCase - ); - if (idx >= 0) { - string codeStr = message[( idx + "exited with code ".Length )..].Trim( ); - if (int.TryParse( - codeStr, - out int code - )) { - return code; - } - } - } - - return null; - } } diff --git a/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs b/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs index fc14f04..dffb05e 100644 --- a/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs +++ b/src/Werkr.Core/Tasks/SuccessCriteriaEvaluator.cs @@ -13,11 +13,10 @@ namespace Werkr.Core.Tasks; /// Custom criteria expressions are predefined string keys evaluated against the typed result. /// /// Logger instance. -public sealed partial class SuccessCriteriaEvaluator(ILogger logger) -{ +public sealed partial class SuccessCriteriaEvaluator( ILogger logger ) { - [GeneratedRegex(@"^exitCode\s*==\s*(-?\d+)$", RegexOptions.IgnoreCase)] - private static partial Regex ExitCodePattern(); + [GeneratedRegex( @"^exitCode\s*==\s*(-?\d+)$", RegexOptions.IgnoreCase )] + private static partial Regex ExitCodePattern( ); /// /// Evaluates success for a completed job. @@ -125,13 +124,12 @@ private bool EvaluateExpression( // "exitCode == N" — exit code must equal the specified integer Match exitCodeMatch = ExitCodePattern().Match(trimmed); - if (exitCodeMatch.Success && int.TryParse(exitCodeMatch.Groups[1].Value, out int expectedCode)) - { + if (exitCodeMatch.Success && int.TryParse( exitCodeMatch.Groups[1].Value, out int expectedCode )) { bool success = exitCode.HasValue && exitCode.Value == expectedCode; if (!success && logger.IsEnabled( LogLevel.Debug )) { logger.LogDebug( "Criteria 'exitCode == {Expected}' failed: exitCode={Actual}.", - expectedCode.ToString(), + expectedCode.ToString( ), exitCode?.ToString( ) ?? "null" ); } diff --git a/src/Werkr.Core/Tasks/TaskService.cs b/src/Werkr.Core/Tasks/TaskService.cs index 1c6f51d..11c95ef 100644 --- a/src/Werkr.Core/Tasks/TaskService.cs +++ b/src/Werkr.Core/Tasks/TaskService.cs @@ -116,7 +116,6 @@ public async Task UpdateAsync( existing.Enabled = task.Enabled; existing.TimeoutMinutes = task.TimeoutMinutes; existing.SuccessCriteria = task.SuccessCriteria; - existing.ScheduleId = task.ScheduleId; existing.WorkflowId = task.WorkflowId; existing.ActionSubType = task.ActionSubType; existing.ActionParameters = task.ActionParameters; diff --git a/src/Werkr.Core/Werkr.Core.csproj b/src/Werkr.Core/Werkr.Core.csproj index 28221be..1d0df57 100644 --- a/src/Werkr.Core/Werkr.Core.csproj +++ b/src/Werkr.Core/Werkr.Core.csproj @@ -16,8 +16,6 @@ - - diff --git a/src/Werkr.Core/Workflows/WorkflowExecutor.cs b/src/Werkr.Core/Workflows/WorkflowExecutor.cs deleted file mode 100644 index ebe02b4..0000000 --- a/src/Werkr.Core/Workflows/WorkflowExecutor.cs +++ /dev/null @@ -1,561 +0,0 @@ -using System.Threading.Channels; - -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -using Werkr.Common.Models; -using Werkr.Core.Tasks; -using Werkr.Data; -using Werkr.Data.Entities.Registration; -using Werkr.Data.Entities.Tasks; -using Werkr.Data.Entities.Workflows; - -namespace Werkr.Core.Workflows; - -/// -/// Executes a workflow as a DAG with topological ordering, conditional control flow -/// (If/Else/ElseIf/While/Do), per-step agent resolution, and parallel execution -/// of independent steps within the same topological level. -/// -/// Database context. -/// Workflow service for DAG validation and level retrieval. -/// Job execution service for dispatching tasks. -/// Agent resolver for tag-based agent matching. -/// Condition evaluator for control flow expressions. -/// Tracker for publishing real-time step status updates. -/// Logger instance. -public sealed class WorkflowExecutor( - WerkrDbContext dbContext, - WorkflowService workflowService, - JobExecutionService jobExecutionService, - AgentResolver agentResolver, - ConditionEvaluator conditionEvaluator, - WorkflowRunTracker runTracker, - ILogger logger -) { - - /// - /// Executes a workflow, resolving the appropriate agent for each step - /// based on task TargetTags. Supports multi-agent workflows where - /// different steps may execute on different agents. Independent steps - /// at the same topological level execute in parallel. - /// - /// The workflow to execute. - /// Cancellation token. - /// The completed record. - public async Task ExecuteAsync( - Workflow workflow, - CancellationToken ct = default - ) { - // Create workflow run first so cancellation can be recorded - WorkflowRun run = new( ) { - WorkflowId = workflow.Id, - StartTime = DateTime.UtcNow, - Status = WorkflowRunStatus.Running, - }; - _ = dbContext.WorkflowRuns.Add( run ); - _ = await dbContext.SaveChangesAsync( CancellationToken.None ); - - // Start real-time tracking channel for this run - ChannelWriter writer = runTracker.StartTracking( run.Id ); - - if (logger.IsEnabled( LogLevel.Information )) { - logger.LogInformation( "Starting workflow run {RunId} for workflow {WorkflowId} '{WorkflowName}'.", - run.Id.ToString( ), - workflow.Id.ToString( ), - workflow.Name - ); - } - - // Step result map: stepId → completed job - Dictionary stepResults = []; - - // If/Else/ElseIf chain tracking: stepId → whether that branch was taken - Dictionary branchTaken = []; - - try { - // Validate DAG (throws if cycle or control flow error) - _ = await workflowService.ValidateDagAsync( - workflow.Id, - ct - ); - - // Get topological levels for execution - // NOTE: Steps within the same level are independent and could be parallelized - // with an IDbContextFactory pattern. Currently executed - // sequentially because DbContext is not thread-safe. - IReadOnlyList> levels = - await workflowService.GetTopologicalLevelsAsync( - workflow.Id, - ct - ); - - foreach (IReadOnlyList level in levels) { - ct.ThrowIfCancellationRequested( ); - - // Partition steps into chain-bound (If/Else/ElseIf sequences) and parallelizable - List parallelizable = []; - List chainBound = []; - - foreach (WorkflowStep step in level) { - if (step.ControlStatement is ControlStatement.If or - ControlStatement.Else or ControlStatement.ElseIf) { - chainBound.Add( step ); - } else { - parallelizable.Add( step ); - } - } - - // Execute parallelizable steps sequentially within the level. - // DbContext is not thread-safe, so true parallelism requires - // IDbContextFactory (tracked for future enhancement). - foreach (WorkflowStep step in parallelizable) { - ct.ThrowIfCancellationRequested( ); - - StepExecutionResult result = await ExecuteStepAsync( - step, run, stepResults, branchTaken, writer, ct ); - - if (result.Job is not null) { - stepResults[result.StepId] = result.Job; - } - - if (result.Failed) { - run.Status = WorkflowRunStatus.Failed; - run.EndTime = DateTime.UtcNow; - _ = await dbContext.SaveChangesAsync( CancellationToken.None ); - logger.LogWarning( "Workflow run {RunId} failed at step {StepId}: {Error}.", - run.Id.ToString( ), - result.StepId.ToString( ), - result.ErrorMessage - ); - runTracker.CompleteTracking( run.Id ); - return run; - } - } - - // Execute chain-bound steps sequentially (order matters for If/Else evaluation) - foreach (WorkflowStep step in chainBound) { - ct.ThrowIfCancellationRequested( ); - - StepExecutionResult result = await ExecuteStepAsync( - step, run, stepResults, branchTaken, writer, ct ); - - if (result.Job is not null) { - stepResults[result.StepId] = result.Job; - } - - if (result.Failed) { - run.Status = WorkflowRunStatus.Failed; - run.EndTime = DateTime.UtcNow; - _ = await dbContext.SaveChangesAsync( CancellationToken.None ); - logger.LogWarning( "Workflow run {RunId} failed at step {StepId}: {Error}.", - run.Id.ToString( ), - result.StepId.ToString( ), - result.ErrorMessage - ); - runTracker.CompleteTracking( run.Id ); - return run; - } - } - } - - // All steps completed - run.Status = WorkflowRunStatus.Completed; - run.EndTime = DateTime.UtcNow; - _ = await dbContext.SaveChangesAsync( CancellationToken.None ); - - if (logger.IsEnabled( LogLevel.Information )) { - logger.LogInformation( "Workflow run {RunId} completed successfully.", - run.Id.ToString( ) - ); - } - } catch (OperationCanceledException) { - run.Status = WorkflowRunStatus.Cancelled; - run.EndTime = DateTime.UtcNow; - _ = await dbContext.SaveChangesAsync( CancellationToken.None ); - logger.LogWarning( - "Workflow run {RunId} was cancelled.", - run.Id.ToString( ) - ); - } catch (Exception ex) { - run.Status = WorkflowRunStatus.Failed; - run.EndTime = DateTime.UtcNow; - _ = await dbContext.SaveChangesAsync( CancellationToken.None ); - logger.LogError( - ex, - "Workflow run {RunId} failed with unexpected error.", - run.Id.ToString( ) - ); - } finally { - runTracker.CompleteTracking( run.Id ); - } - - return run; - } - - /// - /// Retrieves workflow runs for a workflow. - /// - public async Task> GetRunsAsync( - long workflowId, int limit = 50, CancellationToken ct = default ) => - await dbContext.WorkflowRuns.AsNoTracking( ) - .Where( r => r.WorkflowId == workflowId ) - .OrderByDescending( r => r.StartTime ) - .Take( limit ) - .ToListAsync( ct ); - - /// - /// Retrieves a single workflow run with its jobs. - /// - public async Task GetRunAsync( - Guid runId, - CancellationToken ct = default - ) => - await dbContext.WorkflowRuns.AsNoTracking( ) - .Include( r => r.Jobs ) - .FirstOrDefaultAsync( - r => r.Id == runId, - ct - ); - - /// Executes a single workflow step, handling control flow. - private async Task ExecuteStepAsync( - WorkflowStep step, - WorkflowRun run, - Dictionary stepResults, - Dictionary branchTaken, - ChannelWriter writer, - CancellationToken ct - ) { - - // Build a display name for status updates (WorkflowStep has no Name property) - string stepLabel = $"Step {step.Order} (#{step.Id})"; - - // Publish "Running" status - await writer.WriteAsync( new WorkflowStepStatusUpdate( - run.Id, step.Id, stepLabel, "Running", DateTime.UtcNow, null ), ct ); - - // Load task if not eagerly loaded - WerkrTask? task = step.Task; - if (task is null) { - task = await dbContext.Tasks.AsNoTracking( ).FirstOrDefaultAsync( - t => t.Id == step.TaskId, - ct - ); - if (task is null) { - string taskError = $"Task with Id={step.TaskId} not found for step {step.Id}."; - await writer.WriteAsync( new WorkflowStepStatusUpdate( - run.Id, step.Id, stepLabel, "Failed", DateTime.UtcNow, taskError ), ct ); - return StepExecutionResult.Fail( - step.Id, - taskError - ); - } - } - - // Refine step label now that we have the task name - stepLabel = $"Step {step.Order}: {task.Name}"; - - // Gather predecessor jobs - List predecessorJobs = []; - foreach (WorkflowStepDependency dep in step.Dependencies) { - if (stepResults.TryGetValue( - dep.DependsOnStepId, - out WerkrJob? predJob - )) { - predecessorJobs.Add( predJob ); - } - } - - // Check dependency satisfaction - if (!CheckDependencies( - step, - stepResults - )) { - string depError = $"Dependencies not satisfied for step {step.Id} (DependencyMode={step.DependencyMode})."; - await writer.WriteAsync( new WorkflowStepStatusUpdate( - run.Id, step.Id, stepLabel, "Failed", DateTime.UtcNow, depError ), ct ); - return StepExecutionResult.Fail( - step.Id, - depError - ); - } - - // Evaluate control flow - bool shouldExecute = EvaluateControlFlow( - step, predecessorJobs, branchTaken ); - - if (!shouldExecute) { - if (logger.IsEnabled( LogLevel.Debug )) { - logger.LogDebug( "Step {StepId} skipped by control flow ({ControlStatement}).", - step.Id.ToString( ), - step.ControlStatement.ToString( ) - ); - } - await writer.WriteAsync( new WorkflowStepStatusUpdate( - run.Id, step.Id, stepLabel, "Skipped", DateTime.UtcNow, null ), ct ); - return StepExecutionResult.Skipped( step.Id ); - } - - // Resolve agent for this step - RegisteredConnection? agent = await ResolveAgentForStepAsync( - step, - task, - ct - ); - if (agent is null) { - string agentError = - $"No agent available for step {step.Id} " + - $"(task '{task.Name}', tags=[{string.Join( ", ", task.TargetTags )}])."; - await writer.WriteAsync( new WorkflowStepStatusUpdate( - run.Id, step.Id, stepLabel, "Failed", DateTime.UtcNow, agentError ), ct ); - return StepExecutionResult.Fail( - step.Id, - agentError - ); - } - - // Handle While/Do loops - if (step.ControlStatement is ControlStatement.While or ControlStatement.Do) { - return await ExecuteLoopStepAsync( - step, - task, - agent, - run, - predecessorJobs, - ct - ); - } - - // Execute the step's task - WerkrJob job = await jobExecutionService.ExecuteOnAgentAsync( - task, - agent, - run.Id, - ct - ); - - // Record branch taken for If/ElseIf chains - if (step.ControlStatement is ControlStatement.If or ControlStatement.ElseIf) { - branchTaken[step.Id] = true; - } - - if (logger.IsEnabled( LogLevel.Debug )) { - logger.LogDebug( "Step {StepId} executed: Success={Success}, JobId={JobId}.", - step.Id.ToString( ), - job.Success.ToString( ), - job.Id.ToString( ) - ); - } - - string completionStatus = job.Success ? "Completed" : "Failed"; - await writer.WriteAsync( new WorkflowStepStatusUpdate( - run.Id, step.Id, stepLabel, completionStatus, DateTime.UtcNow, - job.Success ? null : "Job execution failed" ), ct - ); - - return StepExecutionResult.Ok( - step.Id, - job - ); - } - - /// Executes a While or Do loop step. - private async Task ExecuteLoopStepAsync( - WorkflowStep step, - WerkrTask task, - RegisteredConnection agent, - WorkflowRun run, - List predecessorJobs, - CancellationToken ct - ) { - - WerkrJob? lastJob = null; - int iterations = 0; - bool isDoLoop = step.ControlStatement == ControlStatement.Do; - - while (iterations < step.MaxIterations) { - ct.ThrowIfCancellationRequested( ); - - // For While: check condition before execution - // For Do: execute first, then check condition - if (!isDoLoop || iterations > 0) { - IReadOnlyList evalJobs = lastJob is not null ? [lastJob] : predecessorJobs; - bool conditionMet = conditionEvaluator.EvaluateMultiple( - step.ConditionExpression, evalJobs, step.DependencyMode ); - if (!conditionMet) { - break; - } - } - - lastJob = await jobExecutionService.ExecuteOnAgentAsync( - task, - agent, - run.Id, - ct - ); - iterations++; - - if (!lastJob.Success) { - break; - } - } - - if (iterations >= step.MaxIterations) { - logger.LogWarning( "Step {StepId} reached MaxIterations ({Max}).", - step.Id.ToString( ), - step.MaxIterations.ToString( ) - ); - } - - return lastJob is not null - ? StepExecutionResult.Ok( - step.Id, - lastJob - ) - : StepExecutionResult.Skipped( step.Id ); - } - - /// Checks whether dependencies are satisfied based on DependencyMode. - private static bool CheckDependencies( - WorkflowStep step, - Dictionary stepResults - ) { - - if (step.Dependencies.Count == 0) { - return true; // Root step — no dependencies - } - - return step.DependencyMode switch { - DependencyMode.All => - // All predecessors must have a result (they were executed, not necessarily succeeded) - step.Dependencies.All( d => stepResults.ContainsKey( d.DependsOnStepId ) ), - DependencyMode.Any => - // At least one predecessor has a result - step.Dependencies.Any( d => stepResults.ContainsKey( d.DependsOnStepId ) ), - _ => step.Dependencies.All( d => stepResults.ContainsKey( d.DependsOnStepId ) ), - }; - } - - /// Evaluates control flow to determine if a step should execute. - private bool EvaluateControlFlow( - WorkflowStep step, - List predecessorJobs, - Dictionary branchTaken - ) { - - switch (step.ControlStatement) { - case ControlStatement.Sequential: - return true; - - case ControlStatement.If: - bool ifResult = conditionEvaluator.EvaluateMultiple( - step.ConditionExpression, predecessorJobs, step.DependencyMode ); - branchTaken[step.Id] = ifResult; - return ifResult; - - case ControlStatement.ElseIf: { - // Check if any prior If/ElseIf in the chain was taken - bool priorTaken = step.Dependencies.Any( d => - branchTaken.TryGetValue( - d.DependsOnStepId, - out bool taken - ) && taken ); - if (priorTaken) { - branchTaken[step.Id] = false; - return false; - } - bool elseIfResult = conditionEvaluator.EvaluateMultiple( - step.ConditionExpression, predecessorJobs, step.DependencyMode ); - branchTaken[step.Id] = elseIfResult; - return elseIfResult; - } - - case ControlStatement.Else: { - // Execute only if no prior If/ElseIf in the chain was taken - bool anyPriorTaken = step.Dependencies.Any( d => - branchTaken.TryGetValue( - d.DependsOnStepId, - out bool taken - ) && taken ); - return !anyPriorTaken; - } - - case ControlStatement.While: - case ControlStatement.Do: - // Loop execution is handled by ExecuteLoopStepAsync - return true; - - default: - return true; - } - } - - /// Resolves the agent for a workflow step. - private async Task ResolveAgentForStepAsync( - WorkflowStep step, WerkrTask task, CancellationToken ct ) { - - if (step.AgentConnectionIdOverride.HasValue) { - RegisteredConnection? overrideAgent = await dbContext.RegisteredConnections - .FirstOrDefaultAsync( - c => c.Id == step.AgentConnectionIdOverride.Value, - ct - ); - if (overrideAgent is null) { - logger.LogWarning( "AgentConnectionIdOverride {AgentId} not found for step {StepId}.", - step.AgentConnectionIdOverride.Value.ToString( ), - step.Id.ToString( ) - ); - } - return overrideAgent; - } - - return await agentResolver.ResolveAsync( - task.TargetTags, - ct - ); - } - - /// Result of executing a single workflow step. - private sealed record StepExecutionResult( - long StepId, - WerkrJob? Job, - bool Failed, - bool WasSkipped, - string? ErrorMessage - ) { - - public static StepExecutionResult Ok( - long stepId, - WerkrJob job - ) => - new( - stepId, - job, - Failed: false, - WasSkipped: false, - ErrorMessage: null - ); - - public static StepExecutionResult Skipped( long stepId ) => - new( - stepId, - Job: null, - Failed: false, - WasSkipped: true, - ErrorMessage: null - ); - - public static StepExecutionResult Fail( - long stepId, - string errorMessage - ) => - new( - stepId, - Job: null, - Failed: true, - WasSkipped: false, - ErrorMessage: errorMessage - ); - } -} diff --git a/src/Werkr.Core/Workflows/WorkflowRunTracker.cs b/src/Werkr.Core/Workflows/WorkflowRunTracker.cs deleted file mode 100644 index 98fc878..0000000 --- a/src/Werkr.Core/Workflows/WorkflowRunTracker.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Collections.Concurrent; -using System.Threading.Channels; - -using Werkr.Common.Models; - -namespace Werkr.Core.Workflows; - -/// -/// Manages instances per workflow run so that -/// the can publish real-time step updates -/// and UI consumers can stream them via . -/// Registered as a singleton. -/// -public sealed class WorkflowRunTracker { - - private readonly ConcurrentDictionary> _channels = new( ); - - /// - /// Creates and returns a for the given run. - /// Called by when a run begins. - /// - public ChannelWriter StartTracking( Guid runId ) { - Channel channel = Channel.CreateUnbounded( - new UnboundedChannelOptions { SingleWriter = true } ); - _ = _channels.TryAdd( - runId, - channel - ); - return channel.Writer; - } - - /// - /// Completes the channel for a run and removes it from tracking. - /// Called by when the run ends. - /// - public void CompleteTracking( Guid runId ) { - if (_channels.TryRemove( - runId, - out Channel? channel - )) { - _ = channel.Writer.TryComplete( ); - } - } - - /// - /// Returns an that streams step updates - /// for the given run. Returns null if the run is not being tracked. - /// - public IAsyncEnumerable? GetUpdates( Guid runId ) { - return _channels.TryGetValue( - runId, - out Channel? channel - ) ? channel.Reader.ReadAllAsync( ) : null; - } - - /// Returns true if the run is currently being tracked. - public bool IsTracking( Guid runId ) => _channels.ContainsKey( runId ); -} diff --git a/src/Werkr.Core/Workflows/WorkflowService.cs b/src/Werkr.Core/Workflows/WorkflowService.cs index c6e364a..a44fbfe 100644 --- a/src/Werkr.Core/Workflows/WorkflowService.cs +++ b/src/Werkr.Core/Workflows/WorkflowService.cs @@ -76,7 +76,6 @@ public async Task UpdateAsync( existing.Name = workflow.Name; existing.Description = workflow.Description; existing.Enabled = workflow.Enabled; - existing.ScheduleId = workflow.ScheduleId; _ = await dbContext.SaveChangesAsync( ct ); @@ -128,7 +127,8 @@ await dbContext.Workflows .ThenInclude( s => s.Dependencies ) .Include( w => w.Steps ) .ThenInclude( s => s.Task ) - .Include( w => w.Schedule ) + .Include( w => w.WorkflowSchedules ) + .ThenInclude( ws => ws.Schedule ) .AsNoTracking( ) .FirstOrDefaultAsync( w => w.Id == workflowId, @@ -144,7 +144,8 @@ await dbContext.Workflows .ThenInclude( s => s.Dependencies ) .Include( w => w.Steps ) .ThenInclude( s => s.Task ) - .Include( w => w.Schedule ) + .Include( w => w.WorkflowSchedules ) + .ThenInclude( ws => ws.Schedule ) .AsNoTracking( ) .OrderBy( w => w.Name ) .ToListAsync( ct ); diff --git a/src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.Designer.cs b/src/Werkr.Data.Identity/Migrations/Postgres/20260309015501_InitialCreate.Designer.cs similarity index 99% rename from src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.Designer.cs rename to src/Werkr.Data.Identity/Migrations/Postgres/20260309015501_InitialCreate.Designer.cs index 51a9c6f..f46f98d 100644 --- a/src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.Designer.cs +++ b/src/Werkr.Data.Identity/Migrations/Postgres/20260309015501_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace Werkr.Data.Identity.Migrations.Postgres { [DbContext(typeof(PostgresWerkrIdentityDbContext))] - [Migration("20260308034043_InitialCreate")] + [Migration("20260309015501_InitialCreate")] partial class InitialCreate { /// diff --git a/src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.cs b/src/Werkr.Data.Identity/Migrations/Postgres/20260309015501_InitialCreate.cs similarity index 99% rename from src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.cs rename to src/Werkr.Data.Identity/Migrations/Postgres/20260309015501_InitialCreate.cs index 2767fa9..ed9a609 100644 --- a/src/Werkr.Data.Identity/Migrations/Postgres/20260308034043_InitialCreate.cs +++ b/src/Werkr.Data.Identity/Migrations/Postgres/20260309015501_InitialCreate.cs @@ -4,6 +4,7 @@ #nullable disable namespace Werkr.Data.Identity.Migrations.Postgres; + /// public partial class InitialCreate : Migration { /// diff --git a/src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.Designer.cs b/src/Werkr.Data.Identity/Migrations/Sqlite/20260309015522_InitialCreate.Designer.cs similarity index 99% rename from src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.Designer.cs rename to src/Werkr.Data.Identity/Migrations/Sqlite/20260309015522_InitialCreate.Designer.cs index f2f6c38..e2f6807 100644 --- a/src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.Designer.cs +++ b/src/Werkr.Data.Identity/Migrations/Sqlite/20260309015522_InitialCreate.Designer.cs @@ -11,7 +11,7 @@ namespace Werkr.Data.Identity.Migrations.Sqlite { [DbContext(typeof(SqliteWerkrIdentityDbContext))] - [Migration("20260308034120_InitialCreate")] + [Migration("20260309015522_InitialCreate")] partial class InitialCreate { /// diff --git a/src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.cs b/src/Werkr.Data.Identity/Migrations/Sqlite/20260309015522_InitialCreate.cs similarity index 99% rename from src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.cs rename to src/Werkr.Data.Identity/Migrations/Sqlite/20260309015522_InitialCreate.cs index 2397b44..7dcaf26 100644 --- a/src/Werkr.Data.Identity/Migrations/Sqlite/20260308034120_InitialCreate.cs +++ b/src/Werkr.Data.Identity/Migrations/Sqlite/20260309015522_InitialCreate.cs @@ -3,6 +3,7 @@ #nullable disable namespace Werkr.Data.Identity.Migrations.Sqlite; + /// public partial class InitialCreate : Migration { /// diff --git a/src/Werkr.Data/Entities/Schedule/DbSchedule.cs b/src/Werkr.Data/Entities/Schedule/DbSchedule.cs index 3200cc4..2224697 100644 --- a/src/Werkr.Data/Entities/Schedule/DbSchedule.cs +++ b/src/Werkr.Data/Entities/Schedule/DbSchedule.cs @@ -43,4 +43,17 @@ public class DbSchedule : ConcurrencyBase, IKey { /// Link to attached holiday calendar (if any). public ScheduleHolidayCalendar? HolidayCalendarLink { get; set; } + + /// + /// Opt-in flag for catch-up execution. When true, the agent fires missed + /// occurrences for this schedule. Defaults to false for recurring schedules. + /// One-time "Run Now" schedules set this to true. + /// + public bool CatchUpEnabled { get; set; } + + /// Navigation property for task links (many-to-many via TaskSchedule). + public ICollection TaskSchedules { get; set; } = []; + + /// Navigation property for workflow links (many-to-many via WorkflowSchedule). + public ICollection WorkflowSchedules { get; set; } = []; } diff --git a/src/Werkr.Data/Entities/Tasks/TaskSchedule.cs b/src/Werkr.Data/Entities/Tasks/TaskSchedule.cs new file mode 100644 index 0000000..a27a62f --- /dev/null +++ b/src/Werkr.Data/Entities/Tasks/TaskSchedule.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Data.Entities.Tasks; + +/// +/// Many-to-many join entity linking a to a . +/// +[Table( "task_schedules" )] +public class TaskSchedule { + + /// Foreign key to the task. + public long TaskId { get; set; } + + /// Foreign key to the schedule. + public Guid ScheduleId { get; set; } + + /// When this link was created (UTC). + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + + /// + /// Whether this link represents a one-time "Run Now" schedule. + /// Used to identify cleanup targets for expired one-time schedules. + /// + public bool IsOneTime { get; set; } + + /// Navigation property to the task. + [ForeignKey( nameof( TaskId ) )] + public WerkrTask? Task { get; set; } + + /// Navigation property to the schedule. + [ForeignKey( nameof( ScheduleId ) )] + public DbSchedule? Schedule { get; set; } +} diff --git a/src/Werkr.Data/Entities/Tasks/WerkrJob.cs b/src/Werkr.Data/Entities/Tasks/WerkrJob.cs index 4bff8d4..b74a34c 100644 --- a/src/Werkr.Data/Entities/Tasks/WerkrJob.cs +++ b/src/Werkr.Data/Entities/Tasks/WerkrJob.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations.Schema; using Werkr.Data.Entities.Interfaces; using Werkr.Data.Entities.Registration; +using Werkr.Data.Entities.Schedule; namespace Werkr.Data.Entities.Tasks; @@ -61,6 +62,12 @@ public class WerkrJob : ConcurrencyBase, IKey { /// Foreign key to the workflow run, if this job was created as part of a workflow. public Guid? WorkflowRunId { get; set; } + /// + /// Foreign key to the schedule that triggered this job. + /// Null for ad-hoc runs that do not originate from a persistent schedule. + /// + public Guid? ScheduleId { get; set; } + /// Navigation property to the source task. [ForeignKey( nameof( TaskId ) )] public WerkrTask? Task { get; set; } @@ -72,4 +79,8 @@ public class WerkrJob : ConcurrencyBase, IKey { /// Navigation property to the workflow run. [ForeignKey( nameof( WorkflowRunId ) )] public Workflows.WorkflowRun? WorkflowRun { get; set; } + + /// Navigation property to the schedule that triggered this job. + [ForeignKey( nameof( ScheduleId ) )] + public DbSchedule? Schedule { get; set; } } diff --git a/src/Werkr.Data/Entities/Tasks/WerkrTask.cs b/src/Werkr.Data/Entities/Tasks/WerkrTask.cs index 0654f61..d701b78 100644 --- a/src/Werkr.Data/Entities/Tasks/WerkrTask.cs +++ b/src/Werkr.Data/Entities/Tasks/WerkrTask.cs @@ -28,9 +28,6 @@ public class WerkrTask : ConcurrencyBase, IKey { /// The type of action this task performs. public TaskActionType ActionType { get; set; } - /// Foreign key to the schedule, if scheduled. - public Guid? ScheduleId { get; set; } - /// Foreign key to the parent workflow, if part of one. public long? WorkflowId { get; set; } @@ -56,6 +53,12 @@ public class WerkrTask : ConcurrencyBase, IKey { /// Whether this task is enabled for scheduled execution. public bool Enabled { get; set; } = true; + /// + /// Indicates this task was created for a single ad-hoc execution + /// (e.g. console command) and should not appear in the normal task list. + /// + public bool IsEphemeral { get; set; } + /// /// Maximum minutes the task may run before being cancelled. /// Null defaults to 30 minutes in JobExecutionService. @@ -93,4 +96,7 @@ public class WerkrTask : ConcurrencyBase, IKey { /// Navigation property for parent workflow. [ForeignKey( nameof( WorkflowId ) )] public Workflow? Workflow { get; set; } + + /// Navigation property for schedule links (many-to-many via TaskSchedule). + public ICollection TaskSchedules { get; set; } = []; } diff --git a/src/Werkr.Data/Entities/Workflows/Workflow.cs b/src/Werkr.Data/Entities/Workflows/Workflow.cs index 7228688..6ef3429 100644 --- a/src/Werkr.Data/Entities/Workflows/Workflow.cs +++ b/src/Werkr.Data/Entities/Workflows/Workflow.cs @@ -1,7 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Werkr.Data.Entities.Interfaces; -using Werkr.Data.Entities.Schedule; using Werkr.Data.Entities.Tasks; namespace Werkr.Data.Entities.Workflows; @@ -29,13 +28,6 @@ public class Workflow : ConcurrencyBase, IKey { /// Whether the workflow is enabled. public bool Enabled { get; set; } = true; - /// Foreign key to schedule for automated workflow execution. - public Guid? ScheduleId { get; set; } - - /// Navigation to the schedule. - [ForeignKey( nameof( ScheduleId ) )] - public DbSchedule? Schedule { get; set; } - /// Navigation property for workflow steps. public ICollection Steps { get; set; } = []; @@ -44,4 +36,7 @@ public class Workflow : ConcurrencyBase, IKey { /// Navigation property for workflow runs. public ICollection Runs { get; set; } = []; + + /// Navigation property for schedule links (many-to-many via WorkflowSchedule). + public ICollection WorkflowSchedules { get; set; } = []; } diff --git a/src/Werkr.Data/Entities/Workflows/WorkflowSchedule.cs b/src/Werkr.Data/Entities/Workflows/WorkflowSchedule.cs new file mode 100644 index 0000000..e6da13e --- /dev/null +++ b/src/Werkr.Data/Entities/Workflows/WorkflowSchedule.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Werkr.Data.Entities.Schedule; + +namespace Werkr.Data.Entities.Workflows; + +/// +/// Many-to-many join entity linking a to a . +/// +[Table( "workflow_schedules" )] +public class WorkflowSchedule { + + /// Foreign key to the workflow. + public long WorkflowId { get; set; } + + /// Foreign key to the schedule. + public Guid ScheduleId { get; set; } + + /// When this link was created (UTC). + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + + /// + /// Whether this link represents a one-time "Run Now" schedule. + /// Used to identify cleanup targets for expired one-time schedules. + /// + public bool IsOneTime { get; set; } + + /// Navigation property to the workflow. + [ForeignKey( nameof( WorkflowId ) )] + public Workflow? Workflow { get; set; } + + /// Navigation property to the schedule. + [ForeignKey( nameof( ScheduleId ) )] + public DbSchedule? Schedule { get; set; } +} diff --git a/src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.Designer.cs b/src/Werkr.Data/Migrations/Postgres/20260309015413_InitialCreate.Designer.cs similarity index 92% rename from src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.Designer.cs rename to src/Werkr.Data/Migrations/Postgres/20260309015413_InitialCreate.Designer.cs index 34256d8..16ef098 100644 --- a/src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.Designer.cs +++ b/src/Werkr.Data/Migrations/Postgres/20260309015413_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace Werkr.Data.Migrations.Postgres { [DbContext(typeof(PostgresWerkrDbContext))] - [Migration("20260308033431_InitialCreate")] + [Migration("20260309015413_InitialCreate")] partial class InitialCreate { /// @@ -260,6 +260,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("id"); + b.Property("CatchUpEnabled") + .HasColumnType("boolean") + .HasColumnName("catch_up_enabled"); + b.Property("Created") .IsRequired() .HasColumnType("text") @@ -736,6 +740,34 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("weekly_recurrence", "werkr"); }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.TaskSchedule", b => + { + b.Property("TaskId") + .HasColumnType("bigint") + .HasColumnName("task_id"); + + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("CreatedAtUtc") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_at_utc"); + + b.Property("IsOneTime") + .HasColumnType("boolean") + .HasColumnName("is_one_time"); + + b.HasKey("TaskId", "ScheduleId") + .HasName("pk_task_schedules"); + + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_task_schedules_schedule_id"); + + b.ToTable("task_schedules", "werkr"); + }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => { b.Property("Id") @@ -784,6 +816,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("double precision") .HasColumnName("runtime_seconds"); + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + b.Property("StartTime") .IsRequired() .HasColumnType("text") @@ -818,6 +854,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("AgentConnectionId") .HasDatabaseName("ix_jobs_agent_connection_id"); + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_jobs_schedule_id"); + b.HasIndex("TaskId") .HasDatabaseName("ix_jobs_task_id"); @@ -875,6 +914,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasColumnName("enabled"); + b.Property("IsEphemeral") + .HasColumnType("boolean") + .HasColumnName("is_ephemeral"); + b.Property("LastUpdated") .IsRequired() .HasColumnType("text") @@ -886,10 +929,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("character varying(256)") .HasColumnName("name"); - b.Property("ScheduleId") - .HasColumnType("uuid") - .HasColumnName("schedule_id"); - b.Property("SuccessCriteria") .HasMaxLength(500) .HasColumnType("character varying(500)") @@ -961,10 +1000,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("character varying(256)") .HasColumnName("name"); - b.Property("ScheduleId") - .HasColumnType("uuid") - .HasColumnName("schedule_id"); - b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer") @@ -973,9 +1008,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_workflows"); - b.HasIndex("ScheduleId") - .HasDatabaseName("ix_workflows_schedule_id"); - b.ToTable("workflows", "werkr"); }); @@ -1028,6 +1060,34 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("workflow_runs", "werkr"); }); + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowSchedule", b => + { + b.Property("WorkflowId") + .HasColumnType("bigint") + .HasColumnName("workflow_id"); + + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("CreatedAtUtc") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_at_utc"); + + b.Property("IsOneTime") + .HasColumnType("boolean") + .HasColumnName("is_one_time"); + + b.HasKey("WorkflowId", "ScheduleId") + .HasName("pk_workflow_schedules"); + + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_workflow_schedules_schedule_id"); + + b.ToTable("workflow_schedules", "werkr"); + }); + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => { b.Property("Id") @@ -1258,6 +1318,27 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Schedule"); }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.TaskSchedule", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany("TaskSchedules") + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_task_schedules_schedules_schedule_id"); + + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") + .WithMany("TaskSchedules") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_task_schedules_tasks_task_id"); + + b.Navigation("Schedule"); + + b.Navigation("Task"); + }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => { b.HasOne("Werkr.Data.Entities.Registration.RegisteredConnection", "AgentConnection") @@ -1265,6 +1346,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasForeignKey("AgentConnectionId") .HasConstraintName("fk_jobs_registered_connections_agent_connection_id"); + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .HasConstraintName("fk_jobs_schedules_schedule_id"); + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") .WithMany() .HasForeignKey("TaskId") @@ -1279,6 +1365,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("AgentConnection"); + b.Navigation("Schedule"); + b.Navigation("Task"); b.Navigation("WorkflowRun"); @@ -1294,24 +1382,35 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Workflow"); }); - modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => { - b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") - .WithMany() - .HasForeignKey("ScheduleId") - .HasConstraintName("fk_workflows_schedules_schedule_id"); + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Runs") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_runs_workflows_workflow_id"); - b.Navigation("Schedule"); + b.Navigation("Workflow"); }); - modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowSchedule", b => { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany("WorkflowSchedules") + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_schedules_schedules_schedule_id"); + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") - .WithMany("Runs") + .WithMany("WorkflowSchedules") .HasForeignKey("WorkflowId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_workflow_runs_workflows_workflow_id"); + .HasConstraintName("fk_workflow_schedules_workflows_workflow_id"); + + b.Navigation("Schedule"); b.Navigation("Workflow"); }); @@ -1379,7 +1478,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("StartDateTime"); + b.Navigation("TaskSchedules"); + b.Navigation("WeeklyRecurrence"); + + b.Navigation("WorkflowSchedules"); }); modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayCalendar", b => @@ -1396,6 +1499,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("GeneratedDates"); }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrTask", b => + { + b.Navigation("TaskSchedules"); + }); + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => { b.Navigation("Runs"); @@ -1403,6 +1511,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Steps"); b.Navigation("Tasks"); + + b.Navigation("WorkflowSchedules"); }); modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => diff --git a/src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.cs b/src/Werkr.Data/Migrations/Postgres/20260309015413_InitialCreate.cs similarity index 91% rename from src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.cs rename to src/Werkr.Data/Migrations/Postgres/20260309015413_InitialCreate.cs index 50d52ad..b4750b6 100644 --- a/src/Werkr.Data/Migrations/Postgres/20260308033431_InitialCreate.cs +++ b/src/Werkr.Data/Migrations/Postgres/20260309015413_InitialCreate.cs @@ -4,6 +4,7 @@ #nullable disable namespace Werkr.Data.Migrations.Postgres; + /// public partial class InitialCreate : Migration { /// @@ -85,6 +86,7 @@ protected override void Up( MigrationBuilder migrationBuilder ) { id = table.Column( type: "uuid", nullable: false ), name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), stop_task_after_minutes = table.Column( type: "bigint", nullable: false ), + catch_up_enabled = table.Column( type: "boolean", nullable: false ), created = table.Column( type: "text", nullable: false ), last_updated = table.Column( type: "text", nullable: false ), version = table.Column( type: "integer", nullable: false ) @@ -93,6 +95,23 @@ protected override void Up( MigrationBuilder migrationBuilder ) { _ = table.PrimaryKey( "pk_schedules", x => x.id ); } ); + _ = migrationBuilder.CreateTable( + name: "workflows", + schema: "werkr", + columns: table => new { + id = table.Column( type: "bigint", nullable: false ) + .Annotation( "Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn ), + name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), + description = table.Column( type: "character varying(2000)", maxLength: 2000, nullable: false ), + enabled = table.Column( type: "boolean", nullable: false ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflows", x => x.id ); + } ); + _ = migrationBuilder.CreateTable( name: "holiday_rules", schema: "werkr", @@ -310,27 +329,88 @@ protected override void Up( MigrationBuilder migrationBuilder ) { } ); _ = migrationBuilder.CreateTable( - name: "workflows", + name: "tasks", schema: "werkr", columns: table => new { id = table.Column( type: "bigint", nullable: false ) .Annotation( "Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn ), name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), description = table.Column( type: "character varying(2000)", maxLength: 2000, nullable: false ), + action_type = table.Column( type: "text", nullable: false ), + workflow_id = table.Column( type: "bigint", nullable: true ), + content = table.Column( type: "character varying(8000)", maxLength: 8000, nullable: false ), + arguments = table.Column( type: "text", nullable: true ), + target_tags = table.Column( type: "text", nullable: false ), enabled = table.Column( type: "boolean", nullable: false ), - schedule_id = table.Column( type: "uuid", nullable: true ), + is_ephemeral = table.Column( type: "boolean", nullable: false ), + timeout_minutes = table.Column( type: "bigint", nullable: true ), + sync_interval_minutes = table.Column( type: "integer", nullable: false ), + success_criteria = table.Column( type: "character varying(500)", maxLength: 500, nullable: true ), + action_sub_type = table.Column( type: "character varying(30)", maxLength: 30, nullable: true ), + action_parameters = table.Column( type: "text", nullable: true ), created = table.Column( type: "text", nullable: false ), last_updated = table.Column( type: "text", nullable: false ), version = table.Column( type: "integer", nullable: false ) }, constraints: table => { - _ = table.PrimaryKey( "pk_workflows", x => x.id ); + _ = table.PrimaryKey( "pk_tasks", x => x.id ); + _ = table.ForeignKey( + name: "fk_tasks_workflows_workflow_id", + column: x => x.workflow_id, + principalSchema: "werkr", + principalTable: "workflows", + principalColumn: "id" ); + } ); + + _ = migrationBuilder.CreateTable( + name: "workflow_runs", + schema: "werkr", + columns: table => new { + id = table.Column( type: "uuid", nullable: false ), + workflow_id = table.Column( type: "bigint", nullable: false ), + start_time = table.Column( type: "text", nullable: false ), + end_time = table.Column( type: "text", nullable: true ), + status = table.Column( type: "text", nullable: false ), + created = table.Column( type: "text", nullable: false ), + last_updated = table.Column( type: "text", nullable: false ), + version = table.Column( type: "integer", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflow_runs", x => x.id ); _ = table.ForeignKey( - name: "fk_workflows_schedules_schedule_id", + name: "fk_workflow_runs_workflows_workflow_id", + column: x => x.workflow_id, + principalSchema: "werkr", + principalTable: "workflows", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "workflow_schedules", + schema: "werkr", + columns: table => new { + workflow_id = table.Column( type: "bigint", nullable: false ), + schedule_id = table.Column( type: "uuid", nullable: false ), + created_at_utc = table.Column( type: "text", nullable: false ), + is_one_time = table.Column( type: "boolean", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflow_schedules", x => new { x.workflow_id, x.schedule_id } ); + _ = table.ForeignKey( + name: "fk_workflow_schedules_schedules_schedule_id", column: x => x.schedule_id, principalSchema: "werkr", principalTable: "schedules", - principalColumn: "id" ); + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + _ = table.ForeignKey( + name: "fk_workflow_schedules_workflows_workflow_id", + column: x => x.workflow_id, + principalSchema: "werkr", + principalTable: "workflows", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); } ); _ = migrationBuilder.CreateTable( @@ -367,59 +447,28 @@ protected override void Up( MigrationBuilder migrationBuilder ) { } ); _ = migrationBuilder.CreateTable( - name: "tasks", + name: "task_schedules", schema: "werkr", columns: table => new { - id = table.Column( type: "bigint", nullable: false ) - .Annotation( "Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn ), - name = table.Column( type: "character varying(256)", maxLength: 256, nullable: false ), - description = table.Column( type: "character varying(2000)", maxLength: 2000, nullable: false ), - action_type = table.Column( type: "text", nullable: false ), - schedule_id = table.Column( type: "uuid", nullable: true ), - workflow_id = table.Column( type: "bigint", nullable: true ), - content = table.Column( type: "character varying(8000)", maxLength: 8000, nullable: false ), - arguments = table.Column( type: "text", nullable: true ), - target_tags = table.Column( type: "text", nullable: false ), - enabled = table.Column( type: "boolean", nullable: false ), - timeout_minutes = table.Column( type: "bigint", nullable: true ), - sync_interval_minutes = table.Column( type: "integer", nullable: false ), - success_criteria = table.Column( type: "character varying(500)", maxLength: 500, nullable: true ), - action_sub_type = table.Column( type: "character varying(30)", maxLength: 30, nullable: true ), - action_parameters = table.Column( type: "text", nullable: true ), - created = table.Column( type: "text", nullable: false ), - last_updated = table.Column( type: "text", nullable: false ), - version = table.Column( type: "integer", nullable: false ) + task_id = table.Column( type: "bigint", nullable: false ), + schedule_id = table.Column( type: "uuid", nullable: false ), + created_at_utc = table.Column( type: "text", nullable: false ), + is_one_time = table.Column( type: "boolean", nullable: false ) }, constraints: table => { - _ = table.PrimaryKey( "pk_tasks", x => x.id ); + _ = table.PrimaryKey( "pk_task_schedules", x => new { x.task_id, x.schedule_id } ); _ = table.ForeignKey( - name: "fk_tasks_workflows_workflow_id", - column: x => x.workflow_id, + name: "fk_task_schedules_schedules_schedule_id", + column: x => x.schedule_id, principalSchema: "werkr", - principalTable: "workflows", - principalColumn: "id" ); - } ); - - _ = migrationBuilder.CreateTable( - name: "workflow_runs", - schema: "werkr", - columns: table => new { - id = table.Column( type: "uuid", nullable: false ), - workflow_id = table.Column( type: "bigint", nullable: false ), - start_time = table.Column( type: "text", nullable: false ), - end_time = table.Column( type: "text", nullable: true ), - status = table.Column( type: "text", nullable: false ), - created = table.Column( type: "text", nullable: false ), - last_updated = table.Column( type: "text", nullable: false ), - version = table.Column( type: "integer", nullable: false ) - }, - constraints: table => { - _ = table.PrimaryKey( "pk_workflow_runs", x => x.id ); + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); _ = table.ForeignKey( - name: "fk_workflow_runs_workflows_workflow_id", - column: x => x.workflow_id, + name: "fk_task_schedules_tasks_task_id", + column: x => x.task_id, principalSchema: "werkr", - principalTable: "workflows", + principalTable: "tasks", principalColumn: "id", onDelete: ReferentialAction.Cascade ); } ); @@ -483,6 +532,7 @@ protected override void Up( MigrationBuilder migrationBuilder ) { output = table.Column( type: "character varying(2000)", maxLength: 2000, nullable: true ), output_path = table.Column( type: "character varying(512)", maxLength: 512, nullable: true ), workflow_run_id = table.Column( type: "uuid", nullable: true ), + schedule_id = table.Column( type: "uuid", nullable: true ), created = table.Column( type: "text", nullable: false ), last_updated = table.Column( type: "text", nullable: false ), version = table.Column( type: "integer", nullable: false ) @@ -495,6 +545,12 @@ protected override void Up( MigrationBuilder migrationBuilder ) { principalSchema: "werkr", principalTable: "registered_connections", principalColumn: "id" ); + _ = table.ForeignKey( + name: "fk_jobs_schedules_schedule_id", + column: x => x.schedule_id, + principalSchema: "werkr", + principalTable: "schedules", + principalColumn: "id" ); _ = table.ForeignKey( name: "fk_jobs_tasks_task_id", column: x => x.task_id, @@ -567,6 +623,12 @@ protected override void Up( MigrationBuilder migrationBuilder ) { table: "jobs", column: "agent_connection_id" ); + _ = migrationBuilder.CreateIndex( + name: "ix_jobs_schedule_id", + schema: "werkr", + table: "jobs", + column: "schedule_id" ); + _ = migrationBuilder.CreateIndex( name: "ix_jobs_task_id", schema: "werkr", @@ -617,6 +679,12 @@ protected override void Up( MigrationBuilder migrationBuilder ) { column: "schedule_id", unique: true ); + _ = migrationBuilder.CreateIndex( + name: "ix_task_schedules_schedule_id", + schema: "werkr", + table: "task_schedules", + column: "schedule_id" ); + _ = migrationBuilder.CreateIndex( name: "ix_tasks_workflow_id", schema: "werkr", @@ -629,6 +697,12 @@ protected override void Up( MigrationBuilder migrationBuilder ) { table: "workflow_runs", column: "workflow_id" ); + _ = migrationBuilder.CreateIndex( + name: "ix_workflow_schedules_schedule_id", + schema: "werkr", + table: "workflow_schedules", + column: "schedule_id" ); + _ = migrationBuilder.CreateIndex( name: "ix_workflow_step_dependencies_depends_on_step_id", schema: "werkr", @@ -652,12 +726,6 @@ protected override void Up( MigrationBuilder migrationBuilder ) { schema: "werkr", table: "workflow_steps", column: "workflow_id" ); - - _ = migrationBuilder.CreateIndex( - name: "ix_workflows_schedule_id", - schema: "werkr", - table: "workflows", - column: "schedule_id" ); } /// @@ -702,10 +770,18 @@ protected override void Down( MigrationBuilder migrationBuilder ) { name: "schedule_start_datetimeinfo", schema: "werkr" ); + _ = migrationBuilder.DropTable( + name: "task_schedules", + schema: "werkr" ); + _ = migrationBuilder.DropTable( name: "weekly_recurrence", schema: "werkr" ); + _ = migrationBuilder.DropTable( + name: "workflow_schedules", + schema: "werkr" ); + _ = migrationBuilder.DropTable( name: "workflow_step_dependencies", schema: "werkr" ); @@ -718,6 +794,10 @@ protected override void Down( MigrationBuilder migrationBuilder ) { name: "workflow_runs", schema: "werkr" ); + _ = migrationBuilder.DropTable( + name: "schedules", + schema: "werkr" ); + _ = migrationBuilder.DropTable( name: "workflow_steps", schema: "werkr" ); @@ -737,9 +817,5 @@ protected override void Down( MigrationBuilder migrationBuilder ) { _ = migrationBuilder.DropTable( name: "workflows", schema: "werkr" ); - - _ = migrationBuilder.DropTable( - name: "schedules", - schema: "werkr" ); } } diff --git a/src/Werkr.Data/Migrations/Postgres/PostgresWerkrDbContextModelSnapshot.cs b/src/Werkr.Data/Migrations/Postgres/PostgresWerkrDbContextModelSnapshot.cs index 9c23dfd..0a67b59 100644 --- a/src/Werkr.Data/Migrations/Postgres/PostgresWerkrDbContextModelSnapshot.cs +++ b/src/Werkr.Data/Migrations/Postgres/PostgresWerkrDbContextModelSnapshot.cs @@ -257,6 +257,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("id"); + b.Property("CatchUpEnabled") + .HasColumnType("boolean") + .HasColumnName("catch_up_enabled"); + b.Property("Created") .IsRequired() .HasColumnType("text") @@ -733,6 +737,34 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("weekly_recurrence", "werkr"); }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.TaskSchedule", b => + { + b.Property("TaskId") + .HasColumnType("bigint") + .HasColumnName("task_id"); + + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("CreatedAtUtc") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_at_utc"); + + b.Property("IsOneTime") + .HasColumnType("boolean") + .HasColumnName("is_one_time"); + + b.HasKey("TaskId", "ScheduleId") + .HasName("pk_task_schedules"); + + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_task_schedules_schedule_id"); + + b.ToTable("task_schedules", "werkr"); + }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => { b.Property("Id") @@ -781,6 +813,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("double precision") .HasColumnName("runtime_seconds"); + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + b.Property("StartTime") .IsRequired() .HasColumnType("text") @@ -815,6 +851,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("AgentConnectionId") .HasDatabaseName("ix_jobs_agent_connection_id"); + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_jobs_schedule_id"); + b.HasIndex("TaskId") .HasDatabaseName("ix_jobs_task_id"); @@ -872,6 +911,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasColumnName("enabled"); + b.Property("IsEphemeral") + .HasColumnType("boolean") + .HasColumnName("is_ephemeral"); + b.Property("LastUpdated") .IsRequired() .HasColumnType("text") @@ -883,10 +926,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(256)") .HasColumnName("name"); - b.Property("ScheduleId") - .HasColumnType("uuid") - .HasColumnName("schedule_id"); - b.Property("SuccessCriteria") .HasMaxLength(500) .HasColumnType("character varying(500)") @@ -958,10 +997,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(256)") .HasColumnName("name"); - b.Property("ScheduleId") - .HasColumnType("uuid") - .HasColumnName("schedule_id"); - b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer") @@ -970,9 +1005,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_workflows"); - b.HasIndex("ScheduleId") - .HasDatabaseName("ix_workflows_schedule_id"); - b.ToTable("workflows", "werkr"); }); @@ -1025,6 +1057,34 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("workflow_runs", "werkr"); }); + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowSchedule", b => + { + b.Property("WorkflowId") + .HasColumnType("bigint") + .HasColumnName("workflow_id"); + + b.Property("ScheduleId") + .HasColumnType("uuid") + .HasColumnName("schedule_id"); + + b.Property("CreatedAtUtc") + .IsRequired() + .HasColumnType("text") + .HasColumnName("created_at_utc"); + + b.Property("IsOneTime") + .HasColumnType("boolean") + .HasColumnName("is_one_time"); + + b.HasKey("WorkflowId", "ScheduleId") + .HasName("pk_workflow_schedules"); + + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_workflow_schedules_schedule_id"); + + b.ToTable("workflow_schedules", "werkr"); + }); + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => { b.Property("Id") @@ -1255,6 +1315,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Schedule"); }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.TaskSchedule", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany("TaskSchedules") + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_task_schedules_schedules_schedule_id"); + + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") + .WithMany("TaskSchedules") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_task_schedules_tasks_task_id"); + + b.Navigation("Schedule"); + + b.Navigation("Task"); + }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => { b.HasOne("Werkr.Data.Entities.Registration.RegisteredConnection", "AgentConnection") @@ -1262,6 +1343,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("AgentConnectionId") .HasConstraintName("fk_jobs_registered_connections_agent_connection_id"); + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .HasConstraintName("fk_jobs_schedules_schedule_id"); + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") .WithMany() .HasForeignKey("TaskId") @@ -1276,6 +1362,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("AgentConnection"); + b.Navigation("Schedule"); + b.Navigation("Task"); b.Navigation("WorkflowRun"); @@ -1291,24 +1379,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Workflow"); }); - modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => { - b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") - .WithMany() - .HasForeignKey("ScheduleId") - .HasConstraintName("fk_workflows_schedules_schedule_id"); + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Runs") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_runs_workflows_workflow_id"); - b.Navigation("Schedule"); + b.Navigation("Workflow"); }); - modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowSchedule", b => { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany("WorkflowSchedules") + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_schedules_schedules_schedule_id"); + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") - .WithMany("Runs") + .WithMany("WorkflowSchedules") .HasForeignKey("WorkflowId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_workflow_runs_workflows_workflow_id"); + .HasConstraintName("fk_workflow_schedules_workflows_workflow_id"); + + b.Navigation("Schedule"); b.Navigation("Workflow"); }); @@ -1376,7 +1475,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("StartDateTime"); + b.Navigation("TaskSchedules"); + b.Navigation("WeeklyRecurrence"); + + b.Navigation("WorkflowSchedules"); }); modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayCalendar", b => @@ -1393,6 +1496,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("GeneratedDates"); }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrTask", b => + { + b.Navigation("TaskSchedules"); + }); + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => { b.Navigation("Runs"); @@ -1400,6 +1508,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Steps"); b.Navigation("Tasks"); + + b.Navigation("WorkflowSchedules"); }); modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => diff --git a/src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.Designer.cs b/src/Werkr.Data/Migrations/Sqlite/20260309015437_InitialCreate.Designer.cs similarity index 92% rename from src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.Designer.cs rename to src/Werkr.Data/Migrations/Sqlite/20260309015437_InitialCreate.Designer.cs index cd20a73..fead450 100644 --- a/src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.Designer.cs +++ b/src/Werkr.Data/Migrations/Sqlite/20260309015437_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace Werkr.Data.Migrations.Sqlite { [DbContext(typeof(SqliteWerkrDbContext))] - [Migration("20260308033950_InitialCreate")] + [Migration("20260309015437_InitialCreate")] partial class InitialCreate { /// @@ -255,6 +255,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasColumnName("id"); + b.Property("CatchUpEnabled") + .HasColumnType("INTEGER") + .HasColumnName("catch_up_enabled"); + b.Property("Created") .IsRequired() .HasColumnType("TEXT") @@ -728,6 +732,34 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("weekly_recurrence", (string)null); }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.TaskSchedule", b => + { + b.Property("TaskId") + .HasColumnType("INTEGER") + .HasColumnName("task_id"); + + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("CreatedAtUtc") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_at_utc"); + + b.Property("IsOneTime") + .HasColumnType("INTEGER") + .HasColumnName("is_one_time"); + + b.HasKey("TaskId", "ScheduleId") + .HasName("pk_task_schedules"); + + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_task_schedules_schedule_id"); + + b.ToTable("task_schedules", (string)null); + }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => { b.Property("Id") @@ -776,6 +808,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("REAL") .HasColumnName("runtime_seconds"); + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + b.Property("StartTime") .IsRequired() .HasColumnType("TEXT") @@ -810,6 +846,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("AgentConnectionId") .HasDatabaseName("ix_jobs_agent_connection_id"); + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_jobs_schedule_id"); + b.HasIndex("TaskId") .HasDatabaseName("ix_jobs_task_id"); @@ -865,6 +904,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("INTEGER") .HasColumnName("enabled"); + b.Property("IsEphemeral") + .HasColumnType("INTEGER") + .HasColumnName("is_ephemeral"); + b.Property("LastUpdated") .IsRequired() .HasColumnType("TEXT") @@ -876,10 +919,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasColumnName("name"); - b.Property("ScheduleId") - .HasColumnType("TEXT") - .HasColumnName("schedule_id"); - b.Property("SuccessCriteria") .HasMaxLength(500) .HasColumnType("TEXT") @@ -949,10 +988,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasColumnName("name"); - b.Property("ScheduleId") - .HasColumnType("TEXT") - .HasColumnName("schedule_id"); - b.Property("Version") .IsConcurrencyToken() .HasColumnType("INTEGER") @@ -961,9 +996,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_workflows"); - b.HasIndex("ScheduleId") - .HasDatabaseName("ix_workflows_schedule_id"); - b.ToTable("workflows", (string)null); }); @@ -1016,6 +1048,34 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("workflow_runs", (string)null); }); + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowSchedule", b => + { + b.Property("WorkflowId") + .HasColumnType("INTEGER") + .HasColumnName("workflow_id"); + + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("CreatedAtUtc") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_at_utc"); + + b.Property("IsOneTime") + .HasColumnType("INTEGER") + .HasColumnName("is_one_time"); + + b.HasKey("WorkflowId", "ScheduleId") + .HasName("pk_workflow_schedules"); + + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_workflow_schedules_schedule_id"); + + b.ToTable("workflow_schedules", (string)null); + }); + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => { b.Property("Id") @@ -1244,6 +1304,27 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Schedule"); }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.TaskSchedule", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany("TaskSchedules") + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_task_schedules_schedules_schedule_id"); + + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") + .WithMany("TaskSchedules") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_task_schedules_tasks_task_id"); + + b.Navigation("Schedule"); + + b.Navigation("Task"); + }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => { b.HasOne("Werkr.Data.Entities.Registration.RegisteredConnection", "AgentConnection") @@ -1251,6 +1332,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasForeignKey("AgentConnectionId") .HasConstraintName("fk_jobs_registered_connections_agent_connection_id"); + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .HasConstraintName("fk_jobs_schedules_schedule_id"); + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") .WithMany() .HasForeignKey("TaskId") @@ -1265,6 +1351,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("AgentConnection"); + b.Navigation("Schedule"); + b.Navigation("Task"); b.Navigation("WorkflowRun"); @@ -1280,24 +1368,35 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Workflow"); }); - modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => { - b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") - .WithMany() - .HasForeignKey("ScheduleId") - .HasConstraintName("fk_workflows_schedules_schedule_id"); + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Runs") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_runs_workflows_workflow_id"); - b.Navigation("Schedule"); + b.Navigation("Workflow"); }); - modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowSchedule", b => { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany("WorkflowSchedules") + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_schedules_schedules_schedule_id"); + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") - .WithMany("Runs") + .WithMany("WorkflowSchedules") .HasForeignKey("WorkflowId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_workflow_runs_workflows_workflow_id"); + .HasConstraintName("fk_workflow_schedules_workflows_workflow_id"); + + b.Navigation("Schedule"); b.Navigation("Workflow"); }); @@ -1365,7 +1464,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("StartDateTime"); + b.Navigation("TaskSchedules"); + b.Navigation("WeeklyRecurrence"); + + b.Navigation("WorkflowSchedules"); }); modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayCalendar", b => @@ -1382,6 +1485,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("GeneratedDates"); }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrTask", b => + { + b.Navigation("TaskSchedules"); + }); + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => { b.Navigation("Runs"); @@ -1389,6 +1497,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Steps"); b.Navigation("Tasks"); + + b.Navigation("WorkflowSchedules"); }); modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => diff --git a/src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.cs b/src/Werkr.Data/Migrations/Sqlite/20260309015437_InitialCreate.cs similarity index 91% rename from src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.cs rename to src/Werkr.Data/Migrations/Sqlite/20260309015437_InitialCreate.cs index 58ac474..a9f7c60 100644 --- a/src/Werkr.Data/Migrations/Sqlite/20260308033950_InitialCreate.cs +++ b/src/Werkr.Data/Migrations/Sqlite/20260309015437_InitialCreate.cs @@ -3,6 +3,7 @@ #nullable disable namespace Werkr.Data.Migrations.Sqlite; + /// public partial class InitialCreate : Migration { /// @@ -77,6 +78,7 @@ protected override void Up( MigrationBuilder migrationBuilder ) { id = table.Column( type: "TEXT", nullable: false ), name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), stop_task_after_minutes = table.Column( type: "INTEGER", nullable: false ), + catch_up_enabled = table.Column( type: "INTEGER", nullable: false ), created = table.Column( type: "TEXT", nullable: false ), last_updated = table.Column( type: "TEXT", nullable: false ), version = table.Column( type: "INTEGER", nullable: false ) @@ -85,6 +87,22 @@ protected override void Up( MigrationBuilder migrationBuilder ) { _ = table.PrimaryKey( "pk_schedules", x => x.id ); } ); + _ = migrationBuilder.CreateTable( + name: "workflows", + columns: table => new { + id = table.Column( type: "INTEGER", nullable: false ) + .Annotation( "Sqlite:Autoincrement", true ), + name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), + description = table.Column( type: "TEXT", maxLength: 2000, nullable: false ), + enabled = table.Column( type: "INTEGER", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflows", x => x.id ); + } ); + _ = migrationBuilder.CreateTable( name: "holiday_rules", columns: table => new { @@ -283,25 +301,81 @@ protected override void Up( MigrationBuilder migrationBuilder ) { } ); _ = migrationBuilder.CreateTable( - name: "workflows", + name: "tasks", columns: table => new { id = table.Column( type: "INTEGER", nullable: false ) .Annotation( "Sqlite:Autoincrement", true ), name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), description = table.Column( type: "TEXT", maxLength: 2000, nullable: false ), + action_type = table.Column( type: "TEXT", nullable: false ), + workflow_id = table.Column( type: "INTEGER", nullable: true ), + content = table.Column( type: "TEXT", maxLength: 8000, nullable: false ), + arguments = table.Column( type: "TEXT", nullable: true ), + target_tags = table.Column( type: "TEXT", nullable: false ), enabled = table.Column( type: "INTEGER", nullable: false ), - schedule_id = table.Column( type: "TEXT", nullable: true ), + is_ephemeral = table.Column( type: "INTEGER", nullable: false ), + timeout_minutes = table.Column( type: "INTEGER", nullable: true ), + sync_interval_minutes = table.Column( type: "INTEGER", nullable: false ), + success_criteria = table.Column( type: "TEXT", maxLength: 500, nullable: true ), + action_sub_type = table.Column( type: "TEXT", maxLength: 30, nullable: true ), + action_parameters = table.Column( type: "TEXT", nullable: true ), created = table.Column( type: "TEXT", nullable: false ), last_updated = table.Column( type: "TEXT", nullable: false ), version = table.Column( type: "INTEGER", nullable: false ) }, constraints: table => { - _ = table.PrimaryKey( "pk_workflows", x => x.id ); + _ = table.PrimaryKey( "pk_tasks", x => x.id ); + _ = table.ForeignKey( + name: "fk_tasks_workflows_workflow_id", + column: x => x.workflow_id, + principalTable: "workflows", + principalColumn: "id" ); + } ); + + _ = migrationBuilder.CreateTable( + name: "workflow_runs", + columns: table => new { + id = table.Column( type: "TEXT", nullable: false ), + workflow_id = table.Column( type: "INTEGER", nullable: false ), + start_time = table.Column( type: "TEXT", nullable: false ), + end_time = table.Column( type: "TEXT", nullable: true ), + status = table.Column( type: "TEXT", nullable: false ), + created = table.Column( type: "TEXT", nullable: false ), + last_updated = table.Column( type: "TEXT", nullable: false ), + version = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflow_runs", x => x.id ); _ = table.ForeignKey( - name: "fk_workflows_schedules_schedule_id", + name: "fk_workflow_runs_workflows_workflow_id", + column: x => x.workflow_id, + principalTable: "workflows", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + } ); + + _ = migrationBuilder.CreateTable( + name: "workflow_schedules", + columns: table => new { + workflow_id = table.Column( type: "INTEGER", nullable: false ), + schedule_id = table.Column( type: "TEXT", nullable: false ), + created_at_utc = table.Column( type: "TEXT", nullable: false ), + is_one_time = table.Column( type: "INTEGER", nullable: false ) + }, + constraints: table => { + _ = table.PrimaryKey( "pk_workflow_schedules", x => new { x.workflow_id, x.schedule_id } ); + _ = table.ForeignKey( + name: "fk_workflow_schedules_schedules_schedule_id", column: x => x.schedule_id, principalTable: "schedules", - principalColumn: "id" ); + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); + _ = table.ForeignKey( + name: "fk_workflow_schedules_workflows_workflow_id", + column: x => x.workflow_id, + principalTable: "workflows", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); } ); _ = migrationBuilder.CreateTable( @@ -335,55 +409,25 @@ protected override void Up( MigrationBuilder migrationBuilder ) { } ); _ = migrationBuilder.CreateTable( - name: "tasks", + name: "task_schedules", columns: table => new { - id = table.Column( type: "INTEGER", nullable: false ) - .Annotation( "Sqlite:Autoincrement", true ), - name = table.Column( type: "TEXT", maxLength: 256, nullable: false ), - description = table.Column( type: "TEXT", maxLength: 2000, nullable: false ), - action_type = table.Column( type: "TEXT", nullable: false ), - schedule_id = table.Column( type: "TEXT", nullable: true ), - workflow_id = table.Column( type: "INTEGER", nullable: true ), - content = table.Column( type: "TEXT", maxLength: 8000, nullable: false ), - arguments = table.Column( type: "TEXT", nullable: true ), - target_tags = table.Column( type: "TEXT", nullable: false ), - enabled = table.Column( type: "INTEGER", nullable: false ), - timeout_minutes = table.Column( type: "INTEGER", nullable: true ), - sync_interval_minutes = table.Column( type: "INTEGER", nullable: false ), - success_criteria = table.Column( type: "TEXT", maxLength: 500, nullable: true ), - action_sub_type = table.Column( type: "TEXT", maxLength: 30, nullable: true ), - action_parameters = table.Column( type: "TEXT", nullable: true ), - created = table.Column( type: "TEXT", nullable: false ), - last_updated = table.Column( type: "TEXT", nullable: false ), - version = table.Column( type: "INTEGER", nullable: false ) + task_id = table.Column( type: "INTEGER", nullable: false ), + schedule_id = table.Column( type: "TEXT", nullable: false ), + created_at_utc = table.Column( type: "TEXT", nullable: false ), + is_one_time = table.Column( type: "INTEGER", nullable: false ) }, constraints: table => { - _ = table.PrimaryKey( "pk_tasks", x => x.id ); + _ = table.PrimaryKey( "pk_task_schedules", x => new { x.task_id, x.schedule_id } ); _ = table.ForeignKey( - name: "fk_tasks_workflows_workflow_id", - column: x => x.workflow_id, - principalTable: "workflows", - principalColumn: "id" ); - } ); - - _ = migrationBuilder.CreateTable( - name: "workflow_runs", - columns: table => new { - id = table.Column( type: "TEXT", nullable: false ), - workflow_id = table.Column( type: "INTEGER", nullable: false ), - start_time = table.Column( type: "TEXT", nullable: false ), - end_time = table.Column( type: "TEXT", nullable: true ), - status = table.Column( type: "TEXT", nullable: false ), - created = table.Column( type: "TEXT", nullable: false ), - last_updated = table.Column( type: "TEXT", nullable: false ), - version = table.Column( type: "INTEGER", nullable: false ) - }, - constraints: table => { - _ = table.PrimaryKey( "pk_workflow_runs", x => x.id ); + name: "fk_task_schedules_schedules_schedule_id", + column: x => x.schedule_id, + principalTable: "schedules", + principalColumn: "id", + onDelete: ReferentialAction.Cascade ); _ = table.ForeignKey( - name: "fk_workflow_runs_workflows_workflow_id", - column: x => x.workflow_id, - principalTable: "workflows", + name: "fk_task_schedules_tasks_task_id", + column: x => x.task_id, + principalTable: "tasks", principalColumn: "id", onDelete: ReferentialAction.Cascade ); } ); @@ -442,6 +486,7 @@ protected override void Up( MigrationBuilder migrationBuilder ) { output = table.Column( type: "TEXT", maxLength: 2000, nullable: true ), output_path = table.Column( type: "TEXT", maxLength: 512, nullable: true ), workflow_run_id = table.Column( type: "TEXT", nullable: true ), + schedule_id = table.Column( type: "TEXT", nullable: true ), created = table.Column( type: "TEXT", nullable: false ), last_updated = table.Column( type: "TEXT", nullable: false ), version = table.Column( type: "INTEGER", nullable: false ) @@ -453,6 +498,11 @@ protected override void Up( MigrationBuilder migrationBuilder ) { column: x => x.agent_connection_id, principalTable: "registered_connections", principalColumn: "id" ); + _ = table.ForeignKey( + name: "fk_jobs_schedules_schedule_id", + column: x => x.schedule_id, + principalTable: "schedules", + principalColumn: "id" ); _ = table.ForeignKey( name: "fk_jobs_tasks_task_id", column: x => x.task_id, @@ -515,6 +565,11 @@ protected override void Up( MigrationBuilder migrationBuilder ) { table: "jobs", column: "agent_connection_id" ); + _ = migrationBuilder.CreateIndex( + name: "ix_jobs_schedule_id", + table: "jobs", + column: "schedule_id" ); + _ = migrationBuilder.CreateIndex( name: "ix_jobs_task_id", table: "jobs", @@ -557,6 +612,11 @@ protected override void Up( MigrationBuilder migrationBuilder ) { column: "schedule_id", unique: true ); + _ = migrationBuilder.CreateIndex( + name: "ix_task_schedules_schedule_id", + table: "task_schedules", + column: "schedule_id" ); + _ = migrationBuilder.CreateIndex( name: "ix_tasks_workflow_id", table: "tasks", @@ -567,6 +627,11 @@ protected override void Up( MigrationBuilder migrationBuilder ) { table: "workflow_runs", column: "workflow_id" ); + _ = migrationBuilder.CreateIndex( + name: "ix_workflow_schedules_schedule_id", + table: "workflow_schedules", + column: "schedule_id" ); + _ = migrationBuilder.CreateIndex( name: "ix_workflow_step_dependencies_depends_on_step_id", table: "workflow_step_dependencies", @@ -586,11 +651,6 @@ protected override void Up( MigrationBuilder migrationBuilder ) { name: "ix_workflow_steps_workflow_id", table: "workflow_steps", column: "workflow_id" ); - - _ = migrationBuilder.CreateIndex( - name: "ix_workflows_schedule_id", - table: "workflows", - column: "schedule_id" ); } /// @@ -625,9 +685,15 @@ protected override void Down( MigrationBuilder migrationBuilder ) { _ = migrationBuilder.DropTable( name: "schedule_start_datetimeinfo" ); + _ = migrationBuilder.DropTable( + name: "task_schedules" ); + _ = migrationBuilder.DropTable( name: "weekly_recurrence" ); + _ = migrationBuilder.DropTable( + name: "workflow_schedules" ); + _ = migrationBuilder.DropTable( name: "workflow_step_dependencies" ); @@ -637,6 +703,9 @@ protected override void Down( MigrationBuilder migrationBuilder ) { _ = migrationBuilder.DropTable( name: "workflow_runs" ); + _ = migrationBuilder.DropTable( + name: "schedules" ); + _ = migrationBuilder.DropTable( name: "workflow_steps" ); @@ -651,8 +720,5 @@ protected override void Down( MigrationBuilder migrationBuilder ) { _ = migrationBuilder.DropTable( name: "workflows" ); - - _ = migrationBuilder.DropTable( - name: "schedules" ); } } diff --git a/src/Werkr.Data/Migrations/Sqlite/SqliteWerkrDbContextModelSnapshot.cs b/src/Werkr.Data/Migrations/Sqlite/SqliteWerkrDbContextModelSnapshot.cs index b3b7239..404bd1e 100644 --- a/src/Werkr.Data/Migrations/Sqlite/SqliteWerkrDbContextModelSnapshot.cs +++ b/src/Werkr.Data/Migrations/Sqlite/SqliteWerkrDbContextModelSnapshot.cs @@ -252,6 +252,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasColumnName("id"); + b.Property("CatchUpEnabled") + .HasColumnType("INTEGER") + .HasColumnName("catch_up_enabled"); + b.Property("Created") .IsRequired() .HasColumnType("TEXT") @@ -725,6 +729,34 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("weekly_recurrence", (string)null); }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.TaskSchedule", b => + { + b.Property("TaskId") + .HasColumnType("INTEGER") + .HasColumnName("task_id"); + + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("CreatedAtUtc") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_at_utc"); + + b.Property("IsOneTime") + .HasColumnType("INTEGER") + .HasColumnName("is_one_time"); + + b.HasKey("TaskId", "ScheduleId") + .HasName("pk_task_schedules"); + + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_task_schedules_schedule_id"); + + b.ToTable("task_schedules", (string)null); + }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => { b.Property("Id") @@ -773,6 +805,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("REAL") .HasColumnName("runtime_seconds"); + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + b.Property("StartTime") .IsRequired() .HasColumnType("TEXT") @@ -807,6 +843,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("AgentConnectionId") .HasDatabaseName("ix_jobs_agent_connection_id"); + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_jobs_schedule_id"); + b.HasIndex("TaskId") .HasDatabaseName("ix_jobs_task_id"); @@ -862,6 +901,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("INTEGER") .HasColumnName("enabled"); + b.Property("IsEphemeral") + .HasColumnType("INTEGER") + .HasColumnName("is_ephemeral"); + b.Property("LastUpdated") .IsRequired() .HasColumnType("TEXT") @@ -873,10 +916,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasColumnName("name"); - b.Property("ScheduleId") - .HasColumnType("TEXT") - .HasColumnName("schedule_id"); - b.Property("SuccessCriteria") .HasMaxLength(500) .HasColumnType("TEXT") @@ -946,10 +985,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasColumnName("name"); - b.Property("ScheduleId") - .HasColumnType("TEXT") - .HasColumnName("schedule_id"); - b.Property("Version") .IsConcurrencyToken() .HasColumnType("INTEGER") @@ -958,9 +993,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_workflows"); - b.HasIndex("ScheduleId") - .HasDatabaseName("ix_workflows_schedule_id"); - b.ToTable("workflows", (string)null); }); @@ -1013,6 +1045,34 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("workflow_runs", (string)null); }); + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowSchedule", b => + { + b.Property("WorkflowId") + .HasColumnType("INTEGER") + .HasColumnName("workflow_id"); + + b.Property("ScheduleId") + .HasColumnType("TEXT") + .HasColumnName("schedule_id"); + + b.Property("CreatedAtUtc") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_at_utc"); + + b.Property("IsOneTime") + .HasColumnType("INTEGER") + .HasColumnName("is_one_time"); + + b.HasKey("WorkflowId", "ScheduleId") + .HasName("pk_workflow_schedules"); + + b.HasIndex("ScheduleId") + .HasDatabaseName("ix_workflow_schedules_schedule_id"); + + b.ToTable("workflow_schedules", (string)null); + }); + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowStep", b => { b.Property("Id") @@ -1241,6 +1301,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Schedule"); }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.TaskSchedule", b => + { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany("TaskSchedules") + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_task_schedules_schedules_schedule_id"); + + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") + .WithMany("TaskSchedules") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_task_schedules_tasks_task_id"); + + b.Navigation("Schedule"); + + b.Navigation("Task"); + }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrJob", b => { b.HasOne("Werkr.Data.Entities.Registration.RegisteredConnection", "AgentConnection") @@ -1248,6 +1329,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("AgentConnectionId") .HasConstraintName("fk_jobs_registered_connections_agent_connection_id"); + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .HasConstraintName("fk_jobs_schedules_schedule_id"); + b.HasOne("Werkr.Data.Entities.Tasks.WerkrTask", "Task") .WithMany() .HasForeignKey("TaskId") @@ -1262,6 +1348,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("AgentConnection"); + b.Navigation("Schedule"); + b.Navigation("Task"); b.Navigation("WorkflowRun"); @@ -1277,24 +1365,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Workflow"); }); - modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => { - b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") - .WithMany() - .HasForeignKey("ScheduleId") - .HasConstraintName("fk_workflows_schedules_schedule_id"); + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") + .WithMany("Runs") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_runs_workflows_workflow_id"); - b.Navigation("Schedule"); + b.Navigation("Workflow"); }); - modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => + modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowSchedule", b => { + b.HasOne("Werkr.Data.Entities.Schedule.DbSchedule", "Schedule") + .WithMany("WorkflowSchedules") + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_workflow_schedules_schedules_schedule_id"); + b.HasOne("Werkr.Data.Entities.Workflows.Workflow", "Workflow") - .WithMany("Runs") + .WithMany("WorkflowSchedules") .HasForeignKey("WorkflowId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_workflow_runs_workflows_workflow_id"); + .HasConstraintName("fk_workflow_schedules_workflows_workflow_id"); + + b.Navigation("Schedule"); b.Navigation("Workflow"); }); @@ -1362,7 +1461,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("StartDateTime"); + b.Navigation("TaskSchedules"); + b.Navigation("WeeklyRecurrence"); + + b.Navigation("WorkflowSchedules"); }); modelBuilder.Entity("Werkr.Data.Entities.Schedule.HolidayCalendar", b => @@ -1379,6 +1482,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("GeneratedDates"); }); + modelBuilder.Entity("Werkr.Data.Entities.Tasks.WerkrTask", b => + { + b.Navigation("TaskSchedules"); + }); + modelBuilder.Entity("Werkr.Data.Entities.Workflows.Workflow", b => { b.Navigation("Runs"); @@ -1386,6 +1494,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Steps"); b.Navigation("Tasks"); + + b.Navigation("WorkflowSchedules"); }); modelBuilder.Entity("Werkr.Data.Entities.Workflows.WorkflowRun", b => diff --git a/src/Werkr.Data/WerkrDbContext.cs b/src/Werkr.Data/WerkrDbContext.cs index 2820680..38c9c92 100644 --- a/src/Werkr.Data/WerkrDbContext.cs +++ b/src/Werkr.Data/WerkrDbContext.cs @@ -87,6 +87,12 @@ protected WerkrDbContext( DbContextOptions options ) : base( options ) { } /// Schedule audit log for suppressed occurrences. public DbSet ScheduleAuditLogs => Set( ); + /// Task-to-schedule many-to-many join table. + public DbSet TaskSchedules => Set( ); + + /// Workflow-to-schedule many-to-many join table. + public DbSet WorkflowSchedules => Set( ); + /// protected override void OnModelCreating( ModelBuilder modelBuilder ) { base.OnModelCreating( modelBuilder ); @@ -298,6 +304,36 @@ protected override void OnModelCreating( ModelBuilder modelBuilder ) { .HasForeignKey( e => e.ScheduleId ) .OnDelete( DeleteBehavior.Cascade ); } ); + + // TaskSchedule — many-to-many join between WerkrTask and DbSchedule + _ = modelBuilder.Entity( entity => { + _ = entity.HasKey( e => new { e.TaskId, e.ScheduleId } ); + + _ = entity.HasOne( e => e.Task ) + .WithMany( t => t.TaskSchedules ) + .HasForeignKey( e => e.TaskId ) + .OnDelete( DeleteBehavior.Cascade ); + + _ = entity.HasOne( e => e.Schedule ) + .WithMany( s => s.TaskSchedules ) + .HasForeignKey( e => e.ScheduleId ) + .OnDelete( DeleteBehavior.Cascade ); + } ); + + // WorkflowSchedule — many-to-many join between Workflow and DbSchedule + _ = modelBuilder.Entity( entity => { + _ = entity.HasKey( e => new { e.WorkflowId, e.ScheduleId } ); + + _ = entity.HasOne( e => e.Workflow ) + .WithMany( w => w.WorkflowSchedules ) + .HasForeignKey( e => e.WorkflowId ) + .OnDelete( DeleteBehavior.Cascade ); + + _ = entity.HasOne( e => e.Schedule ) + .WithMany( s => s.WorkflowSchedules ) + .HasForeignKey( e => e.ScheduleId ) + .OnDelete( DeleteBehavior.Cascade ); + } ); } /// diff --git a/src/Werkr.Server/Components/Layout/MainLayout.razor.css b/src/Werkr.Server/Components/Layout/MainLayout.razor.css index 0d66b4a..88473a7 100644 --- a/src/Werkr.Server/Components/Layout/MainLayout.razor.css +++ b/src/Werkr.Server/Components/Layout/MainLayout.razor.css @@ -58,6 +58,8 @@ main { position: sticky; top: 0; overflow-x: hidden; + display: flex; + flex-direction: column; } .top-row { diff --git a/src/Werkr.Server/Components/Layout/NavMenu.razor b/src/Werkr.Server/Components/Layout/NavMenu.razor index 83f116d..1395a6f 100644 --- a/src/Werkr.Server/Components/Layout/NavMenu.razor +++ b/src/Werkr.Server/Components/Layout/NavMenu.razor @@ -1,6 +1,4 @@ -@using System.Reflection - - -internal sealed class AllowPrefixValidator : IPathAllowlistValidator { +/// +/// Initializes a new instance of the +/// class with the specified set of allowed directory prefixes. +/// +internal sealed class AllowPrefixValidator( params string[] allowedPrefixes ) : IPathAllowlistValidator { /// /// The set of directory prefixes that are considered allowed. /// - private readonly string[] _allowedPrefixes; - - /// - /// Initializes a new instance of the - /// class with the specified set of allowed directory prefixes. - /// - public AllowPrefixValidator( params string[] allowedPrefixes ) { - _allowedPrefixes = allowedPrefixes; - } + private readonly string[] _allowedPrefixes = allowedPrefixes; /// /// Validates the specified path against the configured prefixes. diff --git a/src/Test/Werkr.Tests.Agent/Helpers/FailHandler.cs b/src/Test/Werkr.Tests.Agent/Helpers/FailHandler.cs index ee471dc..4ad3f75 100644 --- a/src/Test/Werkr.Tests.Agent/Helpers/FailHandler.cs +++ b/src/Test/Werkr.Tests.Agent/Helpers/FailHandler.cs @@ -9,19 +9,15 @@ namespace Werkr.Tests.Agent.Helpers; /// /// Fake action handler that always fails (returns Success = false, no throw). /// -internal sealed class FailHandler : IActionHandler { - - /// - /// Initializes a new instance of the class with an optional action name. - /// - public FailHandler( string action = "FailAction" ) { - Action = action; - } +/// +/// Initializes a new instance of the class with an optional action name. +/// +internal sealed class FailHandler( string action = "FailAction" ) : IActionHandler { /// /// Gets the action name that this handler is registered under. /// - public string Action { get; } + public string Action { get; } = action; /// /// Executes the handler by writing an error output and returning a failure result. @@ -29,7 +25,8 @@ public FailHandler( string action = "FailAction" ) { public async Task ExecuteAsync( JsonElement parameters, ChannelWriter output, - CancellationToken cancellationToken + string? inputVariableValue = null, + CancellationToken cancellationToken = default ) { await output.WriteAsync( OperatorOutput.Create( diff --git a/src/Test/Werkr.Tests.Agent/Helpers/MockHttpMessageHandler.cs b/src/Test/Werkr.Tests.Agent/Helpers/MockHttpMessageHandler.cs new file mode 100644 index 0000000..346de48 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/MockHttpMessageHandler.cs @@ -0,0 +1,49 @@ +using System.Net; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// A test that returns a configurable response. +/// Used by network handler tests to avoid real HTTP traffic. +/// +/// +/// Creates a handler that invokes the provided delegate for every request. +/// +internal sealed class MockHttpMessageHandler( + Func> handler + ) : HttpMessageHandler { + + private readonly Func> _handler = handler; + + /// + /// Creates a handler that always returns the specified response. + /// + public MockHttpMessageHandler( HttpResponseMessage response ) + : this( ( _, _ ) => Task.FromResult( response ) ) { } + + /// + /// Creates a handler that returns 200 OK with the specified string body. + /// + public static MockHttpMessageHandler Ok( string body = "" ) => + new( new HttpResponseMessage( HttpStatusCode.OK ) { + Content = new StringContent( body ), + } ); + + /// + /// Creates a handler that returns the specified status code with the specified body. + /// + public static MockHttpMessageHandler WithStatus( + HttpStatusCode statusCode, + string body = "" + ) => + new( new HttpResponseMessage( statusCode ) { + Content = new StringContent( body ), + } ); + + /// + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) => + _handler( request, cancellationToken ); +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/SlowHandler.cs b/src/Test/Werkr.Tests.Agent/Helpers/SlowHandler.cs index b8f9523..2a2f794 100644 --- a/src/Test/Werkr.Tests.Agent/Helpers/SlowHandler.cs +++ b/src/Test/Werkr.Tests.Agent/Helpers/SlowHandler.cs @@ -10,19 +10,15 @@ namespace Werkr.Tests.Agent.Helpers; /// Fake action handler that delays forever (until cancelled). /// Used for timeout and cancellation tests. /// -internal sealed class SlowHandler : IActionHandler { - - /// - /// Initializes a new instance of the class with an optional action name. - /// - public SlowHandler( string action = "SlowAction" ) { - Action = action; - } +/// +/// Initializes a new instance of the class with an optional action name. +/// +internal sealed class SlowHandler( string action = "SlowAction" ) : IActionHandler { /// /// Gets the action name that this handler is registered under. /// - public string Action { get; } + public string Action { get; } = action; /// /// Executes the handler by writing a start message and then blocking @@ -32,7 +28,8 @@ public SlowHandler( string action = "SlowAction" ) { public async Task ExecuteAsync( JsonElement parameters, ChannelWriter output, - CancellationToken cancellationToken + string? inputVariableValue = null, + CancellationToken cancellationToken = default ) { await output.WriteAsync( OperatorOutput.Create( diff --git a/src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs b/src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs index 8af9bb9..ce80f04 100644 --- a/src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs +++ b/src/Test/Werkr.Tests.Agent/Helpers/SuccessHandler.cs @@ -9,19 +9,15 @@ namespace Werkr.Tests.Agent.Helpers; /// /// Fake action handler that always succeeds. Used by . /// -internal sealed class SuccessHandler : IActionHandler { - - /// - /// Initializes a new instance of the class with an optional action name. - /// - public SuccessHandler( string action = "TestAction" ) { - Action = action; - } +/// +/// Initializes a new instance of the class with an optional action name. +/// +internal sealed class SuccessHandler( string action = "TestAction" ) : IActionHandler { /// /// Gets the action name that this handler is registered under. /// - public string Action { get; } + public string Action { get; } = action; /// /// Executes the handler by writing a success output and returning a successful result. @@ -29,7 +25,8 @@ public SuccessHandler( string action = "TestAction" ) { public async Task ExecuteAsync( JsonElement parameters, ChannelWriter output, - CancellationToken cancellationToken + string? inputVariableValue = null, + CancellationToken cancellationToken = default ) { await output.WriteAsync( OperatorOutput.Create( diff --git a/src/Test/Werkr.Tests.Agent/Helpers/TestFilePathResolver.cs b/src/Test/Werkr.Tests.Agent/Helpers/TestFilePathResolver.cs index 4cdad70..140539a 100644 --- a/src/Test/Werkr.Tests.Agent/Helpers/TestFilePathResolver.cs +++ b/src/Test/Werkr.Tests.Agent/Helpers/TestFilePathResolver.cs @@ -16,7 +16,4 @@ internal static class TestFilePathResolver { /// Gets a resolver that denies all paths. public static IFilePathResolver DenyAll { get; } = new FilePathResolver( new DenyAllPathValidator( ) ); - /// Creates a resolver that only allows paths under the given prefixes. - public static IFilePathResolver AllowPrefixes( params string[] prefixes ) => - new FilePathResolver( new AllowPrefixValidator( prefixes ) ); } diff --git a/src/Test/Werkr.Tests.Agent/Helpers/TestHttpClientFactory.cs b/src/Test/Werkr.Tests.Agent/Helpers/TestHttpClientFactory.cs new file mode 100644 index 0000000..dfa413d --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/TestHttpClientFactory.cs @@ -0,0 +1,14 @@ +namespace Werkr.Tests.Agent.Helpers; + +/// +/// A test that returns an +/// backed by a . +/// +/// Creates a factory backed by the specified mock handler. +internal sealed class TestHttpClientFactory( MockHttpMessageHandler handler ) : IHttpClientFactory { + + private readonly MockHttpMessageHandler _handler = handler; + + /// + public HttpClient CreateClient( string name ) => new( _handler ); +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/TestSecretStore.cs b/src/Test/Werkr.Tests.Agent/Helpers/TestSecretStore.cs new file mode 100644 index 0000000..42cb6bb --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/TestSecretStore.cs @@ -0,0 +1,30 @@ +using Werkr.Core.Security; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// A test backed by a simple in-memory dictionary. +/// +internal sealed class TestSecretStore : ISecretStore { + + private readonly Dictionary _secrets = new( StringComparer.OrdinalIgnoreCase ); + + /// Sets a secret value in the in-memory store. + public void Set( string key, string value ) => _secrets[key] = value; + + /// + public Task GetSecretAsync( string key ) => + Task.FromResult( _secrets.TryGetValue( key, out string? value ) ? value : null ); + + /// + public Task SetSecretAsync( string key, string value ) { + _secrets[key] = value; + return Task.CompletedTask; + } + + /// + public Task DeleteSecretAsync( string key ) { + _ = _secrets.Remove( key ); + return Task.CompletedTask; + } +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/TestUrlValidator.cs b/src/Test/Werkr.Tests.Agent/Helpers/TestUrlValidator.cs new file mode 100644 index 0000000..b5bab6f --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Helpers/TestUrlValidator.cs @@ -0,0 +1,33 @@ +using Werkr.Core.Security; + +namespace Werkr.Tests.Agent.Helpers; + +/// +/// Provides pre-built instances for unit tests. +/// +internal static class TestUrlValidator { + + /// + /// A validator that allows all URLs, returning a parsed + /// without any network-actions gate, allowlist, or SSRF checks. + /// + public static IUrlValidator AllowAll { get; } = new AllowAllUrlValidator( ); + + /// + /// A validator that rejects all URLs with . + /// + public static IUrlValidator DenyAll { get; } = new DenyAllUrlValidator( ); + + private sealed class AllowAllUrlValidator : IUrlValidator { + public Uri ValidateUrl( string url ) { + return !Uri.TryCreate( url, UriKind.Absolute, out Uri? uri ) + ? throw new UnauthorizedAccessException( $"Invalid URL: '{url}'" ) + : uri; + } + } + + private sealed class DenyAllUrlValidator : IUrlValidator { + public Uri ValidateUrl( string url ) => + throw new UnauthorizedAccessException( $"URL validation denied (test): '{url}'" ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Helpers/ThrowHandler.cs b/src/Test/Werkr.Tests.Agent/Helpers/ThrowHandler.cs index 05beeea..8caf1d1 100644 --- a/src/Test/Werkr.Tests.Agent/Helpers/ThrowHandler.cs +++ b/src/Test/Werkr.Tests.Agent/Helpers/ThrowHandler.cs @@ -8,19 +8,15 @@ namespace Werkr.Tests.Agent.Helpers; /// /// Fake action handler that always throws an exception. /// -internal sealed class ThrowHandler : IActionHandler { - - /// - /// Initializes a new instance of the class with an optional action name. - /// - public ThrowHandler( string action = "ThrowAction" ) { - Action = action; - } +/// +/// Initializes a new instance of the class with an optional action name. +/// +internal sealed class ThrowHandler( string action = "ThrowAction" ) : IActionHandler { /// /// Gets the action name that this handler is registered under. /// - public string Action { get; } + public string Action { get; } = action; /// /// Always throws an to simulate an unexpected handler failure. @@ -28,7 +24,8 @@ public ThrowHandler( string action = "ThrowAction" ) { public Task ExecuteAsync( JsonElement parameters, ChannelWriter output, - CancellationToken cancellationToken + string? inputVariableValue = null, + CancellationToken cancellationToken = default ) { throw new InvalidOperationException( "Simulated handler failure." ); } diff --git a/src/Test/Werkr.Tests.Agent/Operators/ActionOperatorTests.cs b/src/Test/Werkr.Tests.Agent/Operators/ActionOperatorTests.cs index 0ce0b15..6b2b3b1 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/ActionOperatorTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/ActionOperatorTests.cs @@ -30,7 +30,7 @@ public class ActionOperatorTests { /// with the given timeout. Defaults to a one-hour timeout when none /// is supplied. /// - private static IOptionsMonitor DefaultOptions( TimeSpan? timeout = null ) { + private static TestOptionsMonitor DefaultOptions( TimeSpan? timeout = null ) { ActionOperatorConfiguration config = new( ) { DefaultTimeout = timeout ?? TimeSpan.FromHours( 1 ), }; @@ -119,7 +119,7 @@ public void Constructor_EmptyExpectedActions_SkipsValidation( ) { /// [TestMethod] public void Constructor_NullExpectedActions_UsesDefaultList( ) { - // With null expectedActions, it uses DefaultExpectedActions which requires 11 handlers + // With null expectedActions, it uses DefaultExpectedActions which requires 19 handlers IActionHandler[] handlers = [new SuccessHandler( "A" )]; _ = Assert.ThrowsExactly( @@ -146,7 +146,7 @@ public async Task Execute_SuccessHandler_ReturnsSuccess( ) { ActionDescriptor descriptor = TestActionDescriptor.Create( "TestAction" ); OperatorExecution execution = op.Execute( descriptor, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = []; @@ -177,7 +177,7 @@ public async Task Execute_FailHandler_ReturnsFailure( ) { ActionDescriptor descriptor = TestActionDescriptor.Create( "FailAction" ); OperatorExecution execution = op.Execute( descriptor, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput _ in execution.Output.WithCancellation( TestContext.CancellationToken )) { } @@ -203,7 +203,7 @@ public async Task Execute_ThrowHandler_ReturnsFailureWithException( ) { ActionDescriptor descriptor = TestActionDescriptor.Create( "ThrowAction" ); OperatorExecution execution = op.Execute( descriptor, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput _ in execution.Output.WithCancellation( TestContext.CancellationToken )) { } @@ -232,7 +232,7 @@ public async Task Execute_UnknownAction_ReturnsFailure( ) { ActionDescriptor descriptor = TestActionDescriptor.Create( "UnknownAction" ); OperatorExecution execution = op.Execute( descriptor, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = []; @@ -268,7 +268,7 @@ public async Task Execute_Timeout_ReturnsFailure( ) { ActionDescriptor descriptor = TestActionDescriptor.Create( "SlowAction" ); OperatorExecution execution = op.Execute( descriptor, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = []; @@ -306,7 +306,7 @@ public async Task Execute_Cancellation_ReturnsFailure( ) { ActionDescriptor descriptor = TestActionDescriptor.Create( "SlowAction" ); OperatorExecution execution = op.Execute( descriptor, - cts.Token + cancellationToken: cts.Token ); List outputs = []; @@ -339,7 +339,7 @@ public async Task Execute_CaseInsensitiveActionName_DispatchesCorrectly( ) { ActionDescriptor descriptor = TestActionDescriptor.Create( "testaction" ); OperatorExecution execution = op.Execute( descriptor, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput _ in execution.Output.WithCancellation( TestContext.CancellationToken )) { } @@ -367,7 +367,7 @@ public async Task Execute_NullTimeout_NoTimeoutApplied( ) { ActionDescriptor descriptor = TestActionDescriptor.Create( "TestAction" ); OperatorExecution execution = op.Execute( descriptor, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput _ in execution.Output.WithCancellation( TestContext.CancellationToken )) { } @@ -380,19 +380,15 @@ public async Task Execute_NullTimeout_NoTimeoutApplied( ) { /// /// Simple implementation for tests. /// - private sealed class TestOptionsMonitor : IOptionsMonitor { - - /// - /// Initializes a new instance of the class. - /// - public TestOptionsMonitor( T currentValue ) { - CurrentValue = currentValue; - } + /// + /// Initializes a new instance of the class. + /// + private sealed class TestOptionsMonitor( T currentValue ) : IOptionsMonitor { /// /// Gets the current options value. /// - public T CurrentValue { get; } + public T CurrentValue { get; } = currentValue; /// /// Returns the current value regardless of the supplied . diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/ClearContentHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/ClearContentHandlerTests.cs index 87f543c..3d9e25b 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/Actions/ClearContentHandlerTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/ClearContentHandlerTests.cs @@ -92,7 +92,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -121,7 +121,7 @@ public async Task ClearContent_FileNotFound_ReturnsFailure( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -149,7 +149,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/CompressArchiveHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/CompressArchiveHandlerTests.cs new file mode 100644 index 0000000..1b61d33 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/CompressArchiveHandlerTests.cs @@ -0,0 +1,264 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Validates Zip and TarGz compression, overwrite behavior, and edge cases. +/// +[TestClass] +public class CompressArchiveHandlerTests { + + /// + /// Temporary directory created for each test and cleaned up afterward. + /// + private string _tempDir = null!; + /// + /// The handler instance under test. + /// + private CompressArchiveHandler _handler = null!; + /// + /// Unbounded channel used to capture messages. + /// + private Channel _channel = null!; + + /// + /// Gets or sets the MSTest for the current test run. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Creates a unique temporary directory, the handler, and an unbounded output channel. + /// + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( + Path.GetTempPath( ), + $"werkr-test-{Guid.NewGuid( )}" + ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new CompressArchiveHandler( + TestFilePathResolver.AllowAll, + NullLogger.Instance + ); + _channel = Channel.CreateUnbounded( ); + } + + /// + /// Deletes the temporary directory and all its contents. + /// + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( + _tempDir, + recursive: true + ); + } + } + + /// + /// Serializes a value to a using the shared test serializer. + /// + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + /// + /// Verifies that compressing a single file into a Zip archive succeeds. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CompressArchive_SingleFile_Zip_Succeeds( ) { + string sourceFile = Path.Combine( _tempDir, "data.txt" ); + await File.WriteAllTextAsync( sourceFile, "hello world", TestContext.CancellationToken ); + string destFile = Path.Combine( _tempDir, "archive.zip" ); + + JsonElement parameters = Serialize( new CompressArchiveParameters { + Source = sourceFile, + Destination = destFile, + Format = ArchiveFormat.Zip + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( destFile ) ); + + // Verify archive contains the file + using ZipArchive archive = ZipFile.OpenRead( destFile ); + Assert.HasCount( 1, archive.Entries ); + Assert.AreEqual( "data.txt", archive.Entries[0].Name ); + } + + /// + /// Verifies that compressing a directory into a Zip archive includes all files. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CompressArchive_Directory_Zip_IncludesAllFiles( ) { + string sourceDir = Path.Combine( _tempDir, "src" ); + _ = Directory.CreateDirectory( sourceDir ); + await File.WriteAllTextAsync( Path.Combine( sourceDir, "a.txt" ), "a", TestContext.CancellationToken ); + await File.WriteAllTextAsync( Path.Combine( sourceDir, "b.txt" ), "b", TestContext.CancellationToken ); + string subDir = Path.Combine( sourceDir, "sub" ); + _ = Directory.CreateDirectory( subDir ); + await File.WriteAllTextAsync( Path.Combine( subDir, "c.txt" ), "c", TestContext.CancellationToken ); + + string destFile = Path.Combine( _tempDir, "archive.zip" ); + + JsonElement parameters = Serialize( new CompressArchiveParameters { + Source = sourceDir, + Destination = destFile, + Format = ArchiveFormat.Zip + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + using ZipArchive archive = ZipFile.OpenRead( destFile ); + Assert.HasCount( 3, archive.Entries ); + } + + /// + /// Verifies that compressing to TarGz format succeeds and produces a valid file. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CompressArchive_SingleFile_TarGz_Succeeds( ) { + string sourceFile = Path.Combine( _tempDir, "data.txt" ); + await File.WriteAllTextAsync( sourceFile, "hello world", TestContext.CancellationToken ); + string destFile = Path.Combine( _tempDir, "archive.tar.gz" ); + + JsonElement parameters = Serialize( new CompressArchiveParameters { + Source = sourceFile, + Destination = destFile, + Format = ArchiveFormat.TarGz + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( destFile ) ); + Assert.IsGreaterThan( 0L, new FileInfo( destFile ).Length ); + } + + /// + /// Verifies that existing archives are not overwritten when Overwrite is false. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CompressArchive_ExistingArchive_NoOverwrite_Fails( ) { + string sourceFile = Path.Combine( _tempDir, "data.txt" ); + await File.WriteAllTextAsync( sourceFile, "hello", TestContext.CancellationToken ); + string destFile = Path.Combine( _tempDir, "archive.zip" ); + await File.WriteAllTextAsync( destFile, "existing", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new CompressArchiveParameters { + Source = sourceFile, + Destination = destFile, + Format = ArchiveFormat.Zip, + Overwrite = false + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// + /// Verifies that Overwrite=true replaces an existing archive. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CompressArchive_ExistingArchive_Overwrite_Succeeds( ) { + string sourceFile = Path.Combine( _tempDir, "data.txt" ); + await File.WriteAllTextAsync( sourceFile, "hello", TestContext.CancellationToken ); + string destFile = Path.Combine( _tempDir, "archive.zip" ); + await File.WriteAllTextAsync( destFile, "existing", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new CompressArchiveParameters { + Source = sourceFile, + Destination = destFile, + Format = ArchiveFormat.Zip, + Overwrite = true + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + } + + /// + /// Verifies that Format.Auto is rejected for compression. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CompressArchive_AutoFormat_ReturnsFailure( ) { + string sourceFile = Path.Combine( _tempDir, "data.txt" ); + await File.WriteAllTextAsync( sourceFile, "hello", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new CompressArchiveParameters { + Source = sourceFile, + Destination = Path.Combine( _tempDir, "archive.zip" ), + Format = ArchiveFormat.Auto + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + } + + /// + /// Verifies that a denied source path returns failure with UnauthorizedAccessException. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task CompressArchive_DeniedPath_ReturnsFailure( ) { + CompressArchiveHandler denied = new( + TestFilePathResolver.DenyAll, + NullLogger.Instance + ); + + JsonElement parameters = Serialize( new CompressArchiveParameters { + Source = "/some/path", + Destination = "/some/dest.zip" + } ); + ActionOperatorResult result = await denied.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/CopyFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/CopyFileHandlerTests.cs index 65214e3..c7de1ad 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/Actions/CopyFileHandlerTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/CopyFileHandlerTests.cs @@ -98,7 +98,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -149,7 +149,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -197,7 +197,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -222,7 +222,7 @@ public async Task CopyNoMatch_ReturnsFailure( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -249,7 +249,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -286,7 +286,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await deniedHandler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateDirectoryHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateDirectoryHandlerTests.cs index 940cfef..0d00ff2 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateDirectoryHandlerTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateDirectoryHandlerTests.cs @@ -87,7 +87,7 @@ public async Task CreateDirectory_Succeeds( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -110,7 +110,7 @@ public async Task CreateDirectory_AlreadyExists_StillSucceeds( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -133,7 +133,7 @@ public async Task CreateDirectory_NestedPath_CreatesAll( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -161,7 +161,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateFileHandlerTests.cs index 088fa92..7b84ea0 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateFileHandlerTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/CreateFileHandlerTests.cs @@ -87,7 +87,7 @@ public async Task CreateEmptyFile_Succeeds( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -110,7 +110,7 @@ public async Task CreateFileWithContent_Succeeds( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -140,7 +140,7 @@ public async Task CreateFileCreatesParentDirectories( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -168,7 +168,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -197,7 +197,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -230,7 +230,7 @@ public async Task CreateFile_NoParentNoCreate_ReturnsFailure( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/DelayHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/DelayHandlerTests.cs new file mode 100644 index 0000000..23e111a --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/DelayHandlerTests.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Validates delay execution, cancellation behavior, and reason output. +/// +[TestClass] +public class DelayHandlerTests { + + /// + /// The handler instance under test. + /// + private DelayHandler _handler = null!; + /// + /// Fake time provider for deterministic delay testing. + /// + private FakeTimeProvider _timeProvider = null!; + /// + /// Unbounded channel used to capture messages. + /// + private Channel _channel = null!; + + /// + /// Gets or sets the MSTest for the current test run. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Creates the handler with a fake time provider and an unbounded output channel. + /// + [TestInitialize] + public void TestInit( ) { + _timeProvider = new FakeTimeProvider( ); + _handler = new DelayHandler( + NullLogger.Instance, + _timeProvider + ); + _channel = Channel.CreateUnbounded( ); + } + + /// + /// Serializes a value to a using the shared test serializer. + /// + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + /// + /// Verifies that a delay of zero seconds completes immediately. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Delay_ZeroSeconds_CompletesImmediately( ) { + JsonElement parameters = Serialize( new DelayParameters { Seconds = 0 } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + } + + /// + /// Verifies that advancing the fake clock completes a non-zero delay. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Delay_PositiveSeconds_CompletesAfterAdvance( ) { + JsonElement parameters = Serialize( new DelayParameters { Seconds = 10 } ); + + Task task = _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + _timeProvider.Advance( TimeSpan.FromSeconds( 10 ) ); + + ActionOperatorResult result = await task; + + Assert.IsTrue( result.Success ); + } + + /// + /// Verifies that a reason message appears in the output channel. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Delay_WithReason_WritesReasonToOutput( ) { + JsonElement parameters = Serialize( new DelayParameters { Seconds = 0, Reason = "waiting for deploy" } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + _channel.Writer.Complete( ); + List messages = [ ]; + await foreach (OperatorOutput msg in _channel.Reader.ReadAllAsync( TestContext.CancellationToken )) { + messages.Add( msg ); + } + + Assert.IsTrue( + messages.Exists( m => m.Message.Contains( "waiting for deploy" ) ), + "Expected reason text in output." + ); + } + + /// + /// Verifies that cancellation during a delay propagates as OperationCanceledException. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Delay_Cancelled_ThrowsOperationCanceled( ) { + JsonElement parameters = Serialize( new DelayParameters { Seconds = 300 } ); + using CancellationTokenSource cts = new( ); + + Task task = _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: cts.Token + ); + + await cts.CancelAsync( ); + + _ = await Assert.ThrowsExactlyAsync( ( ) => task ); + } + + /// + /// Verifies that a negative Seconds value produces a failure result. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Delay_NegativeSeconds_ReturnsFailure( ) { + JsonElement parameters = Serialize( new DelayParameters { Seconds = -1 } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/DeleteFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/DeleteFileHandlerTests.cs index 95824ac..1843dc6 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/Actions/DeleteFileHandlerTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/DeleteFileHandlerTests.cs @@ -92,7 +92,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -121,7 +121,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -143,7 +143,7 @@ public async Task DeleteNonExistent_ReturnsFailure( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -170,7 +170,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/DownloadFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/DownloadFileHandlerTests.cs new file mode 100644 index 0000000..ab62b4e --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/DownloadFileHandlerTests.cs @@ -0,0 +1,240 @@ +using System.Net; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Uses to avoid real HTTP traffic. +/// +[TestClass] +public class DownloadFileHandlerTests { + + /// Temporary directory for download destination tests. + private string _tempDir = null!; + /// Unbounded channel for capturing messages. + private Channel _channel = null!; + + /// Gets or sets the MSTest test context. + public TestContext TestContext { get; set; } = null!; + + /// Sets up the temp directory and output channel. + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + _channel = Channel.CreateUnbounded( ); + } + + /// Cleans up the temp directory. + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + private static DownloadFileHandler CreateHandler( + MockHttpMessageHandler mockHttp, + bool allowUrls = true, + bool allowFiles = true + ) => + new( + allowUrls ? TestUrlValidator.AllowAll : TestUrlValidator.DenyAll, + new TestHttpClientFactory( mockHttp ), + allowFiles ? TestFilePathResolver.AllowAll : TestFilePathResolver.DenyAll, + NullLogger.Instance + ); + + /// Verifies a successful download writes content to the destination file. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DownloadFile_Success_WritesFile( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( "file-content" ); + DownloadFileHandler handler = CreateHandler( mock ); + + string dest = Path.Combine( _tempDir, "downloaded.txt" ); + JsonElement parameters = Serialize( new DownloadFileParameters { + Url = "https://example.com/file.txt", + Destination = dest, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( dest ) ); + string content = await File.ReadAllTextAsync( dest, TestContext.CancellationToken ); + Assert.AreEqual( "file-content", content ); + } + + /// Verifies the output variable contains download metadata. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DownloadFile_OutputVariable_ContainsMetadata( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( "data" ); + DownloadFileHandler handler = CreateHandler( mock ); + + string dest = Path.Combine( _tempDir, "meta.txt" ); + JsonElement parameters = Serialize( new DownloadFileParameters { + Url = "https://example.com/file.txt", + Destination = dest, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsNotNull( result.OutputVariableValue ); + + using JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue ); + Assert.AreEqual( dest, doc.RootElement.GetProperty( "path" ).GetString( ) ); + Assert.IsGreaterThan( 0L, doc.RootElement.GetProperty( "size" ).GetInt64( ) ); + } + + /// Verifies that existing files are not overwritten when Overwrite is false. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DownloadFile_ExistingFile_NoOverwrite_Fails( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( "new-content" ); + DownloadFileHandler handler = CreateHandler( mock ); + + string dest = Path.Combine( _tempDir, "existing.txt" ); + await File.WriteAllTextAsync( dest, "original", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new DownloadFileParameters { + Url = "https://example.com/file.txt", + Destination = dest, + Overwrite = false, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + string content = await File.ReadAllTextAsync( dest, TestContext.CancellationToken ); + Assert.AreEqual( "original", content ); + } + + /// Verifies that existing files are overwritten when Overwrite is true. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DownloadFile_ExistingFile_Overwrite_Succeeds( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( "new-content" ); + DownloadFileHandler handler = CreateHandler( mock ); + + string dest = Path.Combine( _tempDir, "existing.txt" ); + await File.WriteAllTextAsync( dest, "original", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new DownloadFileParameters { + Url = "https://example.com/file.txt", + Destination = dest, + Overwrite = true, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + string content = await File.ReadAllTextAsync( dest, TestContext.CancellationToken ); + Assert.AreEqual( "new-content", content ); + } + + /// Verifies that a denied URL causes failure. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DownloadFile_DeniedUrl_Fails( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( ); + DownloadFileHandler handler = CreateHandler( mock, allowUrls: false ); + + string dest = Path.Combine( _tempDir, "denied.txt" ); + JsonElement parameters = Serialize( new DownloadFileParameters { + Url = "https://example.com/file.txt", + Destination = dest, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// Verifies that a denied destination path causes failure. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DownloadFile_DeniedDestination_Fails( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( "data" ); + DownloadFileHandler handler = CreateHandler( mock, allowFiles: false ); + + JsonElement parameters = Serialize( new DownloadFileParameters { + Url = "https://example.com/file.txt", + Destination = "/denied/file.txt", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } + + /// Verifies that server errors cause failure. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DownloadFile_ServerError_Fails( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.WithStatus( HttpStatusCode.InternalServerError ); + DownloadFileHandler handler = CreateHandler( mock ); + + string dest = Path.Combine( _tempDir, "error.txt" ); + JsonElement parameters = Serialize( new DownloadFileParameters { + Url = "https://example.com/file.txt", + Destination = dest, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsFalse( File.Exists( dest ) ); + } + + /// Verifies the Action property returns the correct name. + [TestMethod] + public void DownloadFile_ActionProperty_IsCorrect( ) { + DownloadFileHandler handler = CreateHandler( MockHttpMessageHandler.Ok( ) ); + Assert.AreEqual( "DownloadFile", handler.Action ); + } + + /// Verifies intermediate directories are created. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DownloadFile_CreatesIntermediateDirectories( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( "nested" ); + DownloadFileHandler handler = CreateHandler( mock ); + + string dest = Path.Combine( _tempDir, "sub", "dir", "file.txt" ); + JsonElement parameters = Serialize( new DownloadFileParameters { + Url = "https://example.com/file.txt", + Destination = dest, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( dest ) ); + string content = await File.ReadAllTextAsync( dest, TestContext.CancellationToken ); + Assert.AreEqual( "nested", content ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/ExpandArchiveHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/ExpandArchiveHandlerTests.cs new file mode 100644 index 0000000..3294d89 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/ExpandArchiveHandlerTests.cs @@ -0,0 +1,299 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Validates Zip and TarGz extraction, auto-detection, overwrite, and zip-slip protection. +/// +[TestClass] +public class ExpandArchiveHandlerTests { + + /// + /// Temporary directory created for each test and cleaned up afterward. + /// + private string _tempDir = null!; + /// + /// The handler instance under test. + /// + private ExpandArchiveHandler _handler = null!; + /// + /// Unbounded channel used to capture messages. + /// + private Channel _channel = null!; + + /// + /// Gets or sets the MSTest for the current test run. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Creates a unique temporary directory, the handler, and an unbounded output channel. + /// + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( + Path.GetTempPath( ), + $"werkr-test-{Guid.NewGuid( )}" + ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new ExpandArchiveHandler( + TestFilePathResolver.AllowAll, + NullLogger.Instance + ); + _channel = Channel.CreateUnbounded( ); + } + + /// + /// Deletes the temporary directory and all its contents. + /// + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( + _tempDir, + recursive: true + ); + } + } + + /// + /// Serializes a value to a using the shared test serializer. + /// + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + /// Creates a test Zip archive at the specified path with the given entries. + private static void CreateTestZip( string archivePath, params (string EntryName, string Content)[] entries ) { + using FileStream fs = new( archivePath, FileMode.Create, FileAccess.Write, FileShare.None ); + using ZipArchive archive = new( fs, ZipArchiveMode.Create ); + foreach ((string entryName, string content) in entries) { + ZipArchiveEntry entry = archive.CreateEntry( entryName ); + using StreamWriter writer = new( entry.Open( ) ); + writer.Write( content ); + } + } + + /// + /// Verifies that extracting a Zip archive succeeds and files are on disk. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ExpandArchive_Zip_ExtractsFiles( ) { + string archivePath = Path.Combine( _tempDir, "test.zip" ); + CreateTestZip( archivePath, ("hello.txt", "world"), ("sub/nested.txt", "nested") ); + string destDir = Path.Combine( _tempDir, "output" ); + + JsonElement parameters = Serialize( new ExpandArchiveParameters { + Source = archivePath, + Destination = destDir, + Format = ArchiveFormat.Zip + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( Path.Combine( destDir, "hello.txt" ) ) ); + Assert.IsTrue( File.Exists( Path.Combine( destDir, "sub", "nested.txt" ) ) ); + Assert.AreEqual( "world", await File.ReadAllTextAsync( + Path.Combine( destDir, "hello.txt" ), TestContext.CancellationToken ) ); + } + + /// + /// Verifies that auto-detection correctly identifies a .zip file. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ExpandArchive_AutoDetect_Zip_Succeeds( ) { + string archivePath = Path.Combine( _tempDir, "test.zip" ); + CreateTestZip( archivePath, ("file.txt", "content") ); + string destDir = Path.Combine( _tempDir, "output" ); + + JsonElement parameters = Serialize( new ExpandArchiveParameters { + Source = archivePath, + Destination = destDir + // Format defaults to Auto + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( Path.Combine( destDir, "file.txt" ) ) ); + } + + /// + /// Verifies that extraction fails when a file already exists and Overwrite is false. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ExpandArchive_ExistingFile_NoOverwrite_Fails( ) { + string archivePath = Path.Combine( _tempDir, "test.zip" ); + CreateTestZip( archivePath, ("file.txt", "new content") ); + string destDir = Path.Combine( _tempDir, "output" ); + _ = Directory.CreateDirectory( destDir ); + await File.WriteAllTextAsync( + Path.Combine( destDir, "file.txt" ), "old content", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new ExpandArchiveParameters { + Source = archivePath, + Destination = destDir, + Overwrite = false + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// + /// Verifies that Overwrite=true replaces existing files during extraction. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ExpandArchive_ExistingFile_Overwrite_Succeeds( ) { + string archivePath = Path.Combine( _tempDir, "test.zip" ); + CreateTestZip( archivePath, ("file.txt", "new content") ); + string destDir = Path.Combine( _tempDir, "output" ); + _ = Directory.CreateDirectory( destDir ); + await File.WriteAllTextAsync( + Path.Combine( destDir, "file.txt" ), "old content", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new ExpandArchiveParameters { + Source = archivePath, + Destination = destDir, + Overwrite = true + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + string content = await File.ReadAllTextAsync( + Path.Combine( destDir, "file.txt" ), TestContext.CancellationToken ); + Assert.AreEqual( "new content", content ); + } + + /// + /// Verifies that a zip-slip attack vector (entry with ../) is rejected. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ExpandArchive_ZipSlip_Rejected( ) { + // Create a malicious zip with a path traversal entry + string archivePath = Path.Combine( _tempDir, "malicious.zip" ); + using (FileStream fs = new( archivePath, FileMode.Create, FileAccess.Write, FileShare.None )) { + using ZipArchive archive = new( fs, ZipArchiveMode.Create ); + ZipArchiveEntry entry = archive.CreateEntry( "../../../evil.txt" ); + using StreamWriter writer = new( entry.Open( ) ); + writer.Write( "evil content" ); + } + + string destDir = Path.Combine( _tempDir, "output" ); + + JsonElement parameters = Serialize( new ExpandArchiveParameters { + Source = archivePath, + Destination = destDir + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + Assert.Contains( "zip-slip", result.Exception!.Message ); + } + + /// + /// Verifies that an unrecognized extension with Auto format returns failure. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ExpandArchive_UnrecognizedExtension_Auto_Fails( ) { + string archivePath = Path.Combine( _tempDir, "test.xyz" ); + await File.WriteAllTextAsync( archivePath, "not an archive", TestContext.CancellationToken ); + string destDir = Path.Combine( _tempDir, "output" ); + + JsonElement parameters = Serialize( new ExpandArchiveParameters { + Source = archivePath, + Destination = destDir + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + } + + /// + /// Verifies that a missing archive file returns failure. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ExpandArchive_MissingArchive_ReturnsFailure( ) { + string missingPath = Path.Combine( _tempDir, "missing.zip" ); + string destDir = Path.Combine( _tempDir, "output" ); + + JsonElement parameters = Serialize( new ExpandArchiveParameters { + Source = missingPath, + Destination = destDir + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// + /// Verifies that a denied path returns failure with UnauthorizedAccessException. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ExpandArchive_DeniedPath_ReturnsFailure( ) { + ExpandArchiveHandler denied = new( + TestFilePathResolver.DenyAll, + NullLogger.Instance + ); + + JsonElement parameters = Serialize( new ExpandArchiveParameters { + Source = "/some/archive.zip", + Destination = "/some/dest" + } ); + ActionOperatorResult result = await denied.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/FindReplaceHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/FindReplaceHandlerTests.cs new file mode 100644 index 0000000..4777c4b --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/FindReplaceHandlerTests.cs @@ -0,0 +1,257 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Validates plain text replace, regex replace, case sensitivity, and edge cases. +/// +[TestClass] +public class FindReplaceHandlerTests { + + /// + /// Temporary directory created for each test and cleaned up afterward. + /// + private string _tempDir = null!; + /// + /// The handler instance under test. + /// + private FindReplaceHandler _handler = null!; + /// + /// Unbounded channel used to capture messages. + /// + private Channel _channel = null!; + + /// + /// Gets or sets the MSTest for the current test run. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Creates a unique temporary directory, the handler, and an unbounded output channel. + /// + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( + Path.GetTempPath( ), + $"werkr-test-{Guid.NewGuid( )}" + ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new FindReplaceHandler( + TestFilePathResolver.AllowAll, + NullLogger.Instance + ); + _channel = Channel.CreateUnbounded( ); + } + + /// + /// Deletes the temporary directory and all its contents. + /// + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( + _tempDir, + recursive: true + ); + } + } + + /// + /// Serializes a value to a using the shared test serializer. + /// + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + /// + /// Verifies that a plain text case-sensitive replacement works correctly. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task FindReplace_PlainText_ReplacesAll( ) { + string filePath = Path.Combine( _tempDir, "test.txt" ); + await File.WriteAllTextAsync( filePath, "hello world hello", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new FindReplaceParameters { + Path = filePath, + Find = "hello", + Replace = "bye" + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + string content = await File.ReadAllTextAsync( filePath, TestContext.CancellationToken ); + Assert.AreEqual( "bye world bye", content ); + } + + /// + /// Verifies that case-insensitive plain text replacement works. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task FindReplace_CaseInsensitive_ReplacesAll( ) { + string filePath = Path.Combine( _tempDir, "test.txt" ); + await File.WriteAllTextAsync( filePath, "Hello HELLO hello", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new FindReplaceParameters { + Path = filePath, + Find = "hello", + Replace = "bye", + CaseSensitive = false + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + string content = await File.ReadAllTextAsync( filePath, TestContext.CancellationToken ); + Assert.AreEqual( "bye bye bye", content ); + } + + /// + /// Verifies that regex replacement with capture groups works. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task FindReplace_Regex_ReplacesWithGroups( ) { + string filePath = Path.Combine( _tempDir, "test.txt" ); + await File.WriteAllTextAsync( filePath, "version=1.2.3", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new FindReplaceParameters { + Path = filePath, + Find = @"version=(\d+\.\d+\.\d+)", + Replace = "version=9.9.9", + IsRegex = true + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + string content = await File.ReadAllTextAsync( filePath, TestContext.CancellationToken ); + Assert.AreEqual( "version=9.9.9", content ); + } + + /// + /// Verifies that no matches produces a success with 0 replacements. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task FindReplace_NoMatches_SucceedsWithZeroReplacements( ) { + string filePath = Path.Combine( _tempDir, "test.txt" ); + await File.WriteAllTextAsync( filePath, "hello world", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new FindReplaceParameters { + Path = filePath, + Find = "missing", + Replace = "found" + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + _channel.Writer.Complete( ); + List messages = [ ]; + await foreach (OperatorOutput msg in _channel.Reader.ReadAllAsync( TestContext.CancellationToken )) { + messages.Add( msg ); + } + + Assert.IsTrue( + messages.Exists( m => m.Message.Contains( "0 replacement(s)" ) ), + "Expected zero replacements in output." + ); + } + + /// + /// Verifies that an invalid regex returns failure. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task FindReplace_InvalidRegex_ReturnsFailure( ) { + string filePath = Path.Combine( _tempDir, "test.txt" ); + await File.WriteAllTextAsync( filePath, "content", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new FindReplaceParameters { + Path = filePath, + Find = "[invalid", + Replace = "x", + IsRegex = true + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + } + + /// + /// Verifies that a non-existent file returns failure. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task FindReplace_FileNotFound_ReturnsFailure( ) { + string missingPath = Path.Combine( _tempDir, "missing.txt" ); + + JsonElement parameters = Serialize( new FindReplaceParameters { + Path = missingPath, + Find = "a", + Replace = "b" + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// + /// Verifies that a denied path returns failure with UnauthorizedAccessException. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task FindReplace_DeniedPath_ReturnsFailure( ) { + FindReplaceHandler denied = new( + TestFilePathResolver.DenyAll, + NullLogger.Instance + ); + + JsonElement parameters = Serialize( new FindReplaceParameters { + Path = "/some/path", + Find = "a", + Replace = "b" + } ); + ActionOperatorResult result = await denied.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/ForEachHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/ForEachHandlerTests.cs new file mode 100644 index 0000000..0c14a72 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/ForEachHandlerTests.cs @@ -0,0 +1,365 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Validates JSON object input parsing, array property selection, +/// scalar element validation, and last-element output behavior. +/// +[TestClass] +public class ForEachHandlerTests { + + /// + /// The handler instance under test. + /// + private ForEachHandler _handler = null!; + /// + /// Unbounded channel used to capture messages. + /// + private Channel _channel = null!; + + /// + /// Gets or sets the MSTest for the current test run. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Creates the handler instance and output channel for each test. + /// + [TestInitialize] + public void TestInit( ) { + _handler = new ForEachHandler( + NullLogger.Instance + ); + _channel = Channel.CreateUnbounded( ); + } + + /// + /// Serializes a value to a using the shared test serializer. + /// + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + // ── Happy-Path Tests ───────────────────────────────────────────────────────── + + /// + /// Verifies that an array of strings emits the last element. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_StringArray_EmitsLastElement( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "items" } ); + string input = """{"items": ["alpha", "bravo", "charlie"]}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "\"charlie\"", result.OutputVariableValue ); + } + + /// + /// Verifies that an array of integers emits the last element. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_NumberArray_EmitsLastElement( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "values" } ); + string input = """{"values": [10, 20, 30]}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "30", result.OutputVariableValue ); + } + + /// + /// Verifies that a mixed string/number array is accepted and emits the last element. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_MixedScalarArray_EmitsLastElement( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "data" } ); + string input = """{"data": ["hello", 42, true, false, null]}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "null", result.OutputVariableValue ); + } + + /// + /// Verifies that a single-element array returns that element. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_SingleElement_EmitsIt( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "items" } ); + string input = """{"items": ["only"]}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "\"only\"", result.OutputVariableValue ); + } + + // ── Empty Array ────────────────────────────────────────────────────────────── + + /// + /// Verifies that an empty array succeeds with null output. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_EmptyArray_SucceedsWithNullOutput( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "items" } ); + string input = """{"items": []}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + Assert.IsNull( result.OutputVariableValue ); + } + + // ── Validation Failure Tests ───────────────────────────────────────────────── + + /// + /// Verifies that a non-object input (array root) fails with descriptive error. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_NonObjectInput_Fails( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "items" } ); + string input = """[1, 2, 3]"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "JSON object", result.Exception.Message ); + } + + /// + /// Verifies that a string input (primitive) fails. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_StringInput_Fails( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "items" } ); + string input = "\"just a string\""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "JSON object", result.Exception.Message ); + } + + /// + /// Verifies that a missing ArrayPropertyName in the input object fails. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_MissingProperty_Fails( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "missing" } ); + string input = """{"items": [1, 2, 3]}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "'missing'", result.Exception.Message ); + } + + /// + /// Verifies that a non-array property fails. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_PropertyNotArray_Fails( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "count" } ); + string input = """{"count": 42}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "JSON array", result.Exception.Message ); + } + + /// + /// Verifies that an array containing a complex object element is rejected. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_ComplexObjectElement_Fails( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "items" } ); + string input = """{"items": ["ok", {"nested": true}]}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "scalar", result.Exception.Message ); + } + + /// + /// Verifies that an array containing a nested array element is rejected. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_NestedArrayElement_Fails( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "items" } ); + string input = """{"items": [1, [2, 3]]}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "scalar", result.Exception.Message ); + } + + /// + /// Verifies that null/missing input variable fails with descriptive error. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_NullInput_Fails( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "items" } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: null, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "input variable", result.Exception.Message ); + } + + /// + /// Verifies that invalid JSON in the input variable fails gracefully. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_InvalidJsonInput_Fails( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "items" } ); + string input = "not-json{"; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "valid JSON", result.Exception.Message ); + } + + // ── Boolean/Null Scalar Tests ──────────────────────────────────────────────── + + /// + /// Verifies that boolean true/false array elements are accepted as scalars. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_BooleanArray_EmitsLastElement( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "flags" } ); + string input = """{"flags": [true, false, true]}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "true", result.OutputVariableValue ); + } + + /// + /// Verifies that floating-point numbers in the array are accepted. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ForEach_FloatArray_EmitsLastElement( ) { + JsonElement parameters = Serialize( new ForEachParameters { ArrayPropertyName = "vals" } ); + string input = """{"vals": [1.5, 2.7, 3.14]}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + inputVariableValue: input, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "3.14", result.OutputVariableValue ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/GetFileInfoHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/GetFileInfoHandlerTests.cs new file mode 100644 index 0000000..f02a3b4 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/GetFileInfoHandlerTests.cs @@ -0,0 +1,183 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Validates returning metadata for files, directories, and non-existent paths. +/// +[TestClass] +public class GetFileInfoHandlerTests { + + /// + /// Temporary directory created for each test and cleaned up afterward. + /// + private string _tempDir = null!; + /// + /// The handler instance under test. + /// + private GetFileInfoHandler _handler = null!; + /// + /// Unbounded channel used to capture messages. + /// + private Channel _channel = null!; + + /// + /// Gets or sets the MSTest for the current test run. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Creates a unique temporary directory, the handler, and an unbounded output channel. + /// + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( + Path.GetTempPath( ), + $"werkr-test-{Guid.NewGuid( )}" + ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new GetFileInfoHandler( + TestFilePathResolver.AllowAll, + NullLogger.Instance + ); + _channel = Channel.CreateUnbounded( ); + } + + /// + /// Deletes the temporary directory and all its contents. + /// + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( + _tempDir, + recursive: true + ); + } + } + + /// + /// Serializes a value to a using the shared test serializer. + /// + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + /// + /// Verifies that file metadata is returned correctly for an existing file. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task GetFileInfo_ExistingFile_ReturnsMetadata( ) { + string filePath = Path.Combine( _tempDir, "test.txt" ); + await File.WriteAllTextAsync( filePath, "hello world", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new GetFileInfoParameters { Path = filePath } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + _channel.Writer.Complete( ); + List messages = [ ]; + await foreach (OperatorOutput msg in _channel.Reader.ReadAllAsync( TestContext.CancellationToken )) { + messages.Add( msg ); + } + + Assert.HasCount( 1, messages ); + JsonDocument doc = JsonDocument.Parse( messages[0].Message ); + Assert.IsTrue( doc.RootElement.GetProperty( "exists" ).GetBoolean( ) ); + Assert.IsFalse( doc.RootElement.GetProperty( "isDirectory" ).GetBoolean( ) ); + Assert.IsGreaterThan( 0L, doc.RootElement.GetProperty( "size" ).GetInt64( ) ); + } + + /// + /// Verifies that directory metadata is returned correctly for an existing directory. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task GetFileInfo_ExistingDirectory_ReturnsMetadata( ) { + string dirPath = Path.Combine( _tempDir, "subdir" ); + _ = Directory.CreateDirectory( dirPath ); + + JsonElement parameters = Serialize( new GetFileInfoParameters { Path = dirPath } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + _channel.Writer.Complete( ); + List messages = [ ]; + await foreach (OperatorOutput msg in _channel.Reader.ReadAllAsync( TestContext.CancellationToken )) { + messages.Add( msg ); + } + + Assert.HasCount( 1, messages ); + JsonDocument doc = JsonDocument.Parse( messages[0].Message ); + Assert.IsTrue( doc.RootElement.GetProperty( "exists" ).GetBoolean( ) ); + Assert.IsTrue( doc.RootElement.GetProperty( "isDirectory" ).GetBoolean( ) ); + } + + /// + /// Verifies that a non-existent path returns exists=false and action succeeds. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task GetFileInfo_NonExistentPath_ReturnsNotFound( ) { + string missingPath = Path.Combine( _tempDir, "does-not-exist.txt" ); + + JsonElement parameters = Serialize( new GetFileInfoParameters { Path = missingPath } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + _channel.Writer.Complete( ); + List messages = [ ]; + await foreach (OperatorOutput msg in _channel.Reader.ReadAllAsync( TestContext.CancellationToken )) { + messages.Add( msg ); + } + + Assert.HasCount( 1, messages ); + JsonDocument doc = JsonDocument.Parse( messages[0].Message ); + Assert.IsFalse( doc.RootElement.GetProperty( "exists" ).GetBoolean( ) ); + } + + /// + /// Verifies that a denied path returns failure with UnauthorizedAccessException. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task GetFileInfo_DeniedPath_ReturnsFailure( ) { + GetFileInfoHandler denied = new( + TestFilePathResolver.DenyAll, + NullLogger.Instance + ); + + JsonElement parameters = Serialize( new GetFileInfoParameters { Path = "/some/path" } ); + ActionOperatorResult result = await denied.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/HttpRequestHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/HttpRequestHandlerTests.cs new file mode 100644 index 0000000..ed72120 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/HttpRequestHandlerTests.cs @@ -0,0 +1,260 @@ +using System.Net; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Configuration; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Uses to avoid real HTTP traffic. +/// +[TestClass] +public class HttpRequestHandlerTests { + + /// Temporary directory for file output tests. + private string _tempDir = null!; + /// Unbounded channel for capturing messages. + private Channel _channel = null!; + + /// Gets or sets the MSTest test context. + public TestContext TestContext { get; set; } = null!; + + /// Sets up the temp directory and output channel. + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + _channel = Channel.CreateUnbounded( ); + } + + /// Cleans up the temp directory. + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + private static HttpRequestHandler CreateHandler( + MockHttpMessageHandler mockHttp, + bool allowUrls = true, + bool allowFiles = true + ) => + new( + allowUrls ? TestUrlValidator.AllowAll : TestUrlValidator.DenyAll, + new TestHttpClientFactory( mockHttp ), + allowFiles ? TestFilePathResolver.AllowAll : TestFilePathResolver.DenyAll, + Options.Create( new WorkflowVariableOptions( ) ), + NullLogger.Instance + ); + + /// Verifies a simple GET request returns the response body. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task HttpRequest_Get_ReturnsBody( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( "hello" ); + HttpRequestHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new HttpRequestParameters { Url = "https://example.com/api" } ); + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsNotNull( result.OutputVariableValue ); + + using JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue ); + Assert.AreEqual( 200, doc.RootElement.GetProperty( "statusCode" ).GetInt32( ) ); + Assert.AreEqual( "hello", doc.RootElement.GetProperty( "body" ).GetString( ) ); + } + + /// Verifies POST sends the parameter body. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task HttpRequest_PostWithBody_SendsBody( ) { + string? capturedBody = null; + MockHttpMessageHandler mock = new( async ( req, _ ) => { + capturedBody = await req.Content!.ReadAsStringAsync( CancellationToken.None ); + return new HttpResponseMessage( HttpStatusCode.OK ) { + Content = new StringContent( "ok" ), + }; + } ); + HttpRequestHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new HttpRequestParameters { + Url = "https://example.com/api", + Method = "POST", + Body = "{\"key\":\"value\"}", + ContentType = "application/json", + } ); + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "{\"key\":\"value\"}", capturedBody ); + } + + /// Verifies input variable is used as body when Body parameter is null. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task HttpRequest_PostWithInputVariable_UsesVariableAsBody( ) { + string? capturedBody = null; + MockHttpMessageHandler mock = new( async ( req, _ ) => { + capturedBody = await req.Content!.ReadAsStringAsync( CancellationToken.None ); + return new HttpResponseMessage( HttpStatusCode.OK ) { + Content = new StringContent( "ok" ), + }; + } ); + HttpRequestHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new HttpRequestParameters { + Url = "https://example.com/api", + Method = "POST", + } ); + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: "from-variable", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "from-variable", capturedBody ); + } + + /// Verifies that Body parameter takes precedence over input variable. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task HttpRequest_BodyParamPrecedence_OverInputVariable( ) { + string? capturedBody = null; + MockHttpMessageHandler mock = new( async ( req, _ ) => { + capturedBody = await req.Content!.ReadAsStringAsync( CancellationToken.None ); + return new HttpResponseMessage( HttpStatusCode.OK ) { + Content = new StringContent( "ok" ), + }; + } ); + HttpRequestHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new HttpRequestParameters { + Url = "https://example.com/api", + Method = "POST", + Body = "param-body", + } ); + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: "variable-body", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "param-body", capturedBody ); + } + + /// Verifies that unexpected status codes cause failure. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task HttpRequest_UnexpectedStatusCode_Fails( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.WithStatus( HttpStatusCode.NotFound, "not found" ); + HttpRequestHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new HttpRequestParameters { + Url = "https://example.com/api", + ExpectedStatusCodes = [200], + } ); + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + } + + /// Verifies that custom expected status codes are accepted. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task HttpRequest_CustomExpectedStatusCode_Accepted( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.WithStatus( HttpStatusCode.Created ); + HttpRequestHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new HttpRequestParameters { + Url = "https://example.com/api", + ExpectedStatusCodes = [201], + } ); + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + } + + /// Verifies response body is written to OutputFilePath. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task HttpRequest_OutputFilePath_WritesToFile( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( "file-content" ); + HttpRequestHandler handler = CreateHandler( mock ); + + string outPath = Path.Combine( _tempDir, "response.txt" ); + JsonElement parameters = Serialize( new HttpRequestParameters { + Url = "https://example.com/api", + OutputFilePath = outPath, + } ); + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsTrue( File.Exists( outPath ) ); + string content = await File.ReadAllTextAsync( outPath, TestContext.CancellationToken ); + Assert.AreEqual( "file-content", content ); + } + + /// Verifies that a denied URL causes failure. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task HttpRequest_DeniedUrl_Fails( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( ); + HttpRequestHandler handler = CreateHandler( mock, allowUrls: false ); + + JsonElement parameters = Serialize( new HttpRequestParameters { Url = "https://example.com/api" } ); + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// Verifies that custom headers are sent with the request. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task HttpRequest_CustomHeaders_Sent( ) { + string? authHeader = null; + MockHttpMessageHandler mock = new( ( req, _ ) => { + authHeader = req.Headers.Authorization?.ToString( ); + return Task.FromResult( new HttpResponseMessage( HttpStatusCode.OK ) { + Content = new StringContent( "ok" ), + } ); + } ); + HttpRequestHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new HttpRequestParameters { + Url = "https://example.com/api", + Headers = new Dictionary { ["Authorization"] = "Bearer test-token" }, + } ); + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "Bearer test-token", authHeader ); + } + + /// Verifies the Action property returns the correct name. + [TestMethod] + public void HttpRequest_ActionProperty_IsCorrect( ) { + HttpRequestHandler handler = CreateHandler( MockHttpMessageHandler.Ok( ) ); + Assert.AreEqual( "HttpRequest", handler.Action ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/ListDirectoryHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/ListDirectoryHandlerTests.cs new file mode 100644 index 0000000..77c2f89 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/ListDirectoryHandlerTests.cs @@ -0,0 +1,256 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Validates directory enumeration with pattern matching, type filtering, sorting, and recursion. +/// +[TestClass] +public class ListDirectoryHandlerTests { + + /// + /// Temporary directory created for each test and cleaned up afterward. + /// + private string _tempDir = null!; + /// + /// The handler instance under test. + /// + private ListDirectoryHandler _handler = null!; + /// + /// Unbounded channel used to capture messages. + /// + private Channel _channel = null!; + + /// + /// Gets or sets the MSTest for the current test run. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Creates a unique temporary directory, the handler, and an unbounded output channel. + /// + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( + Path.GetTempPath( ), + $"werkr-test-{Guid.NewGuid( )}" + ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new ListDirectoryHandler( + TestFilePathResolver.AllowAll, + NullLogger.Instance + ); + _channel = Channel.CreateUnbounded( ); + } + + /// + /// Deletes the temporary directory and all its contents. + /// + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( + _tempDir, + recursive: true + ); + } + } + + /// + /// Serializes a value to a using the shared test serializer. + /// + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + /// + /// Collects all messages from the output channel after completing the writer. + /// + private async Task> CollectOutputAsync( ) { + _channel.Writer.Complete( ); + List messages = [ ]; + await foreach (OperatorOutput msg in _channel.Reader.ReadAllAsync( TestContext.CancellationToken )) { + messages.Add( msg ); + } + return messages; + } + + /// + /// Verifies that listing files only returns files, not directories. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ListDirectory_FilesOnly_ReturnsFiles( ) { + await File.WriteAllTextAsync( Path.Combine( _tempDir, "a.txt" ), "content", TestContext.CancellationToken ); + await File.WriteAllTextAsync( Path.Combine( _tempDir, "b.txt" ), "content", TestContext.CancellationToken ); + _ = Directory.CreateDirectory( Path.Combine( _tempDir, "sub" ) ); + + JsonElement parameters = Serialize( new ListDirectoryParameters { + Path = _tempDir, + Type = PathType.File + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + List messages = await CollectOutputAsync( ); + // Second message is the JSON array + string[] files = JsonSerializer.Deserialize( messages[1].Message )!; + Assert.HasCount( 2, files ); + } + + /// + /// Verifies that listing directories only returns directories, not files. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ListDirectory_DirectoriesOnly_ReturnsDirectories( ) { + await File.WriteAllTextAsync( Path.Combine( _tempDir, "a.txt" ), "content", TestContext.CancellationToken ); + _ = Directory.CreateDirectory( Path.Combine( _tempDir, "sub1" ) ); + _ = Directory.CreateDirectory( Path.Combine( _tempDir, "sub2" ) ); + + JsonElement parameters = Serialize( new ListDirectoryParameters { + Path = _tempDir, + Type = PathType.Directory + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + List messages = await CollectOutputAsync( ); + string[] dirs = JsonSerializer.Deserialize( messages[1].Message )!; + Assert.HasCount( 2, dirs ); + } + + /// + /// Verifies that a glob pattern filters entries correctly. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ListDirectory_PatternFilter_ReturnsMatching( ) { + await File.WriteAllTextAsync( Path.Combine( _tempDir, "data.csv" ), "a", TestContext.CancellationToken ); + await File.WriteAllTextAsync( Path.Combine( _tempDir, "notes.txt" ), "b", TestContext.CancellationToken ); + await File.WriteAllTextAsync( Path.Combine( _tempDir, "report.csv" ), "c", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new ListDirectoryParameters { + Path = _tempDir, + Pattern = "*.csv" + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + List messages = await CollectOutputAsync( ); + string[] files = JsonSerializer.Deserialize( messages[1].Message )!; + Assert.HasCount( 2, files ); + } + + /// + /// Verifies that recursive enumeration includes entries from subdirectories. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ListDirectory_Recursive_IncludesSubdirectories( ) { + string subDir = Path.Combine( _tempDir, "sub" ); + _ = Directory.CreateDirectory( subDir ); + await File.WriteAllTextAsync( Path.Combine( _tempDir, "top.txt" ), "a", TestContext.CancellationToken ); + await File.WriteAllTextAsync( Path.Combine( subDir, "nested.txt" ), "b", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new ListDirectoryParameters { + Path = _tempDir, + Recursive = true + } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + List messages = await CollectOutputAsync( ); + string[] files = JsonSerializer.Deserialize( messages[1].Message )!; + Assert.HasCount( 2, files ); + } + + /// + /// Verifies that listing an empty directory succeeds with an empty JSON array. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ListDirectory_EmptyDirectory_ReturnsEmptyArray( ) { + JsonElement parameters = Serialize( new ListDirectoryParameters { Path = _tempDir } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + List messages = await CollectOutputAsync( ); + string[] files = JsonSerializer.Deserialize( messages[1].Message )!; + Assert.IsEmpty( files ); + } + + /// + /// Verifies that listing a non-existent directory returns failure. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ListDirectory_NonExistentDirectory_ReturnsFailure( ) { + string missing = Path.Combine( _tempDir, "does-not-exist" ); + + JsonElement parameters = Serialize( new ListDirectoryParameters { Path = missing } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// + /// Verifies that a denied path returns failure with UnauthorizedAccessException. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ListDirectory_DeniedPath_ReturnsFailure( ) { + ListDirectoryHandler denied = new( + TestFilePathResolver.DenyAll, + NullLogger.Instance + ); + + JsonElement parameters = Serialize( new ListDirectoryParameters { Path = "/some/path" } ); + ActionOperatorResult result = await denied.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/MoveFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/MoveFileHandlerTests.cs index 6286f43..aa892dd 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/Actions/MoveFileHandlerTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/MoveFileHandlerTests.cs @@ -96,7 +96,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -138,7 +138,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -159,7 +159,7 @@ public async Task MoveNoMatch_ReturnsFailure( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -186,7 +186,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/ReadContentHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/ReadContentHandlerTests.cs new file mode 100644 index 0000000..58cdc99 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/ReadContentHandlerTests.cs @@ -0,0 +1,188 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Validates reading file content, truncation via MaxBytes, encoding, and edge cases. +/// +[TestClass] +public class ReadContentHandlerTests { + + /// + /// Temporary directory created for each test and cleaned up afterward. + /// + private string _tempDir = null!; + /// + /// The handler instance under test. + /// + private ReadContentHandler _handler = null!; + /// + /// Unbounded channel used to capture messages. + /// + private Channel _channel = null!; + + /// + /// Gets or sets the MSTest for the current test run. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Creates a unique temporary directory, the handler, and an unbounded output channel. + /// + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( + Path.GetTempPath( ), + $"werkr-test-{Guid.NewGuid( )}" + ); + _ = Directory.CreateDirectory( _tempDir ); + _handler = new ReadContentHandler( + TestFilePathResolver.AllowAll, + NullLogger.Instance + ); + _channel = Channel.CreateUnbounded( ); + } + + /// + /// Deletes the temporary directory and all its contents. + /// + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( + _tempDir, + recursive: true + ); + } + } + + /// + /// Serializes a value to a using the shared test serializer. + /// + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + /// + /// Verifies that reading a UTF-8 file returns its full content. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ReadContent_Utf8File_ReturnsContent( ) { + string filePath = Path.Combine( _tempDir, "test.txt" ); + await File.WriteAllTextAsync( filePath, "hello world", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new ReadContentParameters { Path = filePath } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + _channel.Writer.Complete( ); + List messages = [ ]; + await foreach (OperatorOutput msg in _channel.Reader.ReadAllAsync( TestContext.CancellationToken )) { + messages.Add( msg ); + } + + Assert.HasCount( 1, messages ); + Assert.AreEqual( "hello world", messages[0].Message ); + } + + /// + /// Verifies that MaxBytes truncates the output content. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ReadContent_MaxBytes_TruncatesContent( ) { + string filePath = Path.Combine( _tempDir, "test.txt" ); + await File.WriteAllTextAsync( filePath, "abcdefghijklmnop", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new ReadContentParameters { Path = filePath, MaxBytes = 5 } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + + _channel.Writer.Complete( ); + List messages = [ ]; + await foreach (OperatorOutput msg in _channel.Reader.ReadAllAsync( TestContext.CancellationToken )) { + messages.Add( msg ); + } + + Assert.HasCount( 1, messages ); + Assert.AreEqual( "abcde", messages[0].Message ); + } + + /// + /// Verifies that reading a non-existent file returns failure. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ReadContent_FileNotFound_ReturnsFailure( ) { + string missingPath = Path.Combine( _tempDir, "missing.txt" ); + + JsonElement parameters = Serialize( new ReadContentParameters { Path = missingPath } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// + /// Verifies that reading an empty file succeeds with empty content. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ReadContent_EmptyFile_ReturnsEmptyContent( ) { + string filePath = Path.Combine( _tempDir, "empty.txt" ); + await File.WriteAllTextAsync( filePath, string.Empty, TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new ReadContentParameters { Path = filePath } ); + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + } + + /// + /// Verifies that a denied path returns failure with UnauthorizedAccessException. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task ReadContent_DeniedPath_ReturnsFailure( ) { + ReadContentHandler denied = new( + TestFilePathResolver.DenyAll, + NullLogger.Instance + ); + + JsonElement parameters = Serialize( new ReadContentParameters { Path = "/some/path" } ); + ActionOperatorResult result = await denied.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/RenameFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/RenameFileHandlerTests.cs index 929f189..39d2ccd 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/Actions/RenameFileHandlerTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/RenameFileHandlerTests.cs @@ -93,7 +93,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -118,7 +118,7 @@ public async Task RenameDirectory_Succeeds( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -141,7 +141,7 @@ public async Task RenameNonExistent_ReturnsFailure( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -181,7 +181,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -220,7 +220,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/SendEmailHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/SendEmailHandlerTests.cs new file mode 100644 index 0000000..bff2c94 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/SendEmailHandlerTests.cs @@ -0,0 +1,179 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// These tests focus on configuration gating, parameter validation, and credential +/// loading. Actual SMTP delivery is not tested here (requires a real SMTP server). +/// +[TestClass] +public class SendEmailHandlerTests { + + /// Unbounded channel for capturing messages. + private Channel _channel = null!; + + /// Gets or sets the MSTest test context. + public TestContext TestContext { get; set; } = null!; + + /// Sets up the output channel. + [TestInitialize] + public void TestInit( ) { + _channel = Channel.CreateUnbounded( ); + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + private static SendEmailHandler CreateHandler( + bool enableNetwork = true, + TestSecretStore? secretStore = null + ) { + ActionOperatorConfiguration config = new( ) { + EnableNetworkActions = enableNetwork, + }; + return new SendEmailHandler( + new TestOptionsMonitor( config ), + secretStore ?? new TestSecretStore( ), + TestFilePathResolver.AllowAll, + NullLogger.Instance + ); + } + + /// Verifies the handler rejects when EnableNetworkActions is false. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task SendEmail_NetworkDisabled_Fails( ) { + SendEmailHandler handler = CreateHandler( enableNetwork: false ); + + JsonElement parameters = Serialize( new SendEmailParameters { + SmtpHost = "smtp.example.com", + From = "sender@example.com", + To = ["recipient@example.com"], + Subject = "Test", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// Verifies the handler rejects when there are zero recipients. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task SendEmail_NoRecipients_Fails( ) { + SendEmailHandler handler = CreateHandler( ); + + JsonElement parameters = Serialize( new SendEmailParameters { + SmtpHost = "smtp.example.com", + From = "sender@example.com", + To = [], + Subject = "Test", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// Verifies the handler fails when a credential is specified but missing from the store. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task SendEmail_MissingCredential_Fails( ) { + // When EnableNetworkActions is true but we try to connect to a non-existent SMTP server, + // the handler will fail at the SMTP connect step. However, we can test the credential + // lookup path by verifying the error message when a credential name is given but not found. + // Since we can't mock MailKit's SmtpClient easily, we accept the connection failure. + SendEmailHandler handler = CreateHandler( ); + + JsonElement parameters = Serialize( new SendEmailParameters { + SmtpHost = "127.0.0.1", + Port = 0, // invalid port to fail fast + From = "sender@example.com", + To = ["recipient@example.com"], + Subject = "Test", + CredentialName = "missing-cred", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + } + + /// Verifies the handler fails when an attachment file does not exist. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task SendEmail_MissingAttachment_Fails( ) { + SendEmailHandler handler = CreateHandler( ); + + JsonElement parameters = Serialize( new SendEmailParameters { + SmtpHost = "127.0.0.1", + Port = 0, + From = "sender@example.com", + To = ["recipient@example.com"], + Subject = "Test", + Attachments = ["/nonexistent/file.txt"], + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + } + + /// Verifies the Action property returns the correct name. + [TestMethod] + public void SendEmail_ActionProperty_IsCorrect( ) { + SendEmailHandler handler = CreateHandler( ); + Assert.AreEqual( "SendEmail", handler.Action ); + } + + /// Verifies body falls back to input variable when Body parameter is null. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task SendEmail_BodyFromVariable_UsedWhenBodyNull( ) { + // This test validates up to the SMTP connect step which will fail. + // The key assertion is that the handler does not reject due to missing body. + SendEmailHandler handler = CreateHandler( ); + + JsonElement parameters = Serialize( new SendEmailParameters { + SmtpHost = "127.0.0.1", + Port = 0, + From = "sender@example.com", + To = ["recipient@example.com"], + Subject = "Test", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: "body from variable", + cancellationToken: TestContext.CancellationToken ); + + // Will fail at SMTP connect — that's expected. + // The test passes as long as no ArgumentException is thrown for missing body. + Assert.IsFalse( result.Success ); + Assert.IsNotInstanceOfType( result.Exception ); + } + + /// Local stub. + private sealed class TestOptionsMonitor( T currentValue ) : IOptionsMonitor { + public T CurrentValue { get; } = currentValue; + public T Get( string? name ) => CurrentValue; + public IDisposable? OnChange( Action listener ) => null; + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/SendWebhookHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/SendWebhookHandlerTests.cs new file mode 100644 index 0000000..ce20d0b --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/SendWebhookHandlerTests.cs @@ -0,0 +1,226 @@ +using System.Net; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Uses to avoid real HTTP traffic. +/// +[TestClass] +public class SendWebhookHandlerTests { + + /// Unbounded channel for capturing messages. + private Channel _channel = null!; + + /// Gets or sets the MSTest test context. + public TestContext TestContext { get; set; } = null!; + + /// Sets up the output channel. + [TestInitialize] + public void TestInit( ) { + _channel = Channel.CreateUnbounded( ); + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + private static SendWebhookHandler CreateHandler( + MockHttpMessageHandler mockHttp, + bool allowUrls = true + ) => + new( + allowUrls ? TestUrlValidator.AllowAll : TestUrlValidator.DenyAll, + new TestHttpClientFactory( mockHttp ), + NullLogger.Instance + ); + + /// Verifies a basic webhook POST is sent with payload from parameter. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task SendWebhook_PayloadFromParameter_SendsPost( ) { + string? capturedBody = null; + string? capturedContentType = null; + MockHttpMessageHandler mock = new( async ( req, _ ) => { + capturedBody = await req.Content!.ReadAsStringAsync( CancellationToken.None ); + capturedContentType = req.Content.Headers.ContentType?.MediaType; + return new HttpResponseMessage( HttpStatusCode.OK ) { + Content = new StringContent( "{\"ok\":true}" ), + }; + } ); + SendWebhookHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new SendWebhookParameters { + Url = "https://example.com/webhook", + Payload = "{\"event\":\"test\"}", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "{\"event\":\"test\"}", capturedBody ); + Assert.AreEqual( "application/json", capturedContentType ); + } + + /// Verifies input variable value is used as payload when Payload is null. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task SendWebhook_PayloadFromVariable_SendsPost( ) { + string? capturedBody = null; + MockHttpMessageHandler mock = new( async ( req, _ ) => { + capturedBody = await req.Content!.ReadAsStringAsync( CancellationToken.None ); + return new HttpResponseMessage( HttpStatusCode.OK ) { + Content = new StringContent( "ok" ), + }; + } ); + SendWebhookHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new SendWebhookParameters { + Url = "https://example.com/webhook", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: "{\"from\":\"variable\"}", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "{\"from\":\"variable\"}", capturedBody ); + } + + /// Verifies Payload parameter takes precedence over input variable. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task SendWebhook_PayloadPrecedence_OverVariable( ) { + string? capturedBody = null; + MockHttpMessageHandler mock = new( async ( req, _ ) => { + capturedBody = await req.Content!.ReadAsStringAsync( CancellationToken.None ); + return new HttpResponseMessage( HttpStatusCode.OK ) { + Content = new StringContent( "ok" ), + }; + } ); + SendWebhookHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new SendWebhookParameters { + Url = "https://example.com/webhook", + Payload = "{\"from\":\"param\"}", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: "{\"from\":\"variable\"}", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "{\"from\":\"param\"}", capturedBody ); + } + + /// Verifies default empty body when no payload or variable. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task SendWebhook_NoPayloadNoVariable_SendsEmptyObject( ) { + string? capturedBody = null; + MockHttpMessageHandler mock = new( async ( req, _ ) => { + capturedBody = await req.Content!.ReadAsStringAsync( CancellationToken.None ); + return new HttpResponseMessage( HttpStatusCode.OK ) { + Content = new StringContent( "ok" ), + }; + } ); + SendWebhookHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new SendWebhookParameters { + Url = "https://example.com/webhook", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "{}", capturedBody ); + } + + /// Verifies output variable contains statusCode and responseBody. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task SendWebhook_OutputVariable_ContainsMetadata( ) { + MockHttpMessageHandler mock = new( new HttpResponseMessage( HttpStatusCode.Accepted ) { + Content = new StringContent( "{\"id\":42}" ), + } ); + SendWebhookHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new SendWebhookParameters { + Url = "https://example.com/webhook", + Payload = "{}", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsNotNull( result.OutputVariableValue ); + + using JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue ); + Assert.AreEqual( 202, doc.RootElement.GetProperty( "statusCode" ).GetInt32( ) ); + Assert.AreEqual( "{\"id\":42}", doc.RootElement.GetProperty( "responseBody" ).GetString( ) ); + Assert.IsGreaterThanOrEqualTo( 0L, doc.RootElement.GetProperty( "elapsedMs" ).GetInt64( ) ); + } + + /// Verifies that custom headers are sent with the webhook request. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task SendWebhook_CustomHeaders_Sent( ) { + string? customHeader = null; + MockHttpMessageHandler mock = new( ( req, _ ) => { + customHeader = req.Headers.TryGetValues( "X-Custom", out IEnumerable? vals ) + ? string.Join( ",", vals ) + : null; + return Task.FromResult( new HttpResponseMessage( HttpStatusCode.OK ) { + Content = new StringContent( "ok" ), + } ); + } ); + SendWebhookHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new SendWebhookParameters { + Url = "https://example.com/webhook", + Payload = "{}", + Headers = new Dictionary { ["X-Custom"] = "test-value" }, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "test-value", customHeader ); + } + + /// Verifies denied URL causes failure. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task SendWebhook_DeniedUrl_Fails( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( ); + SendWebhookHandler handler = CreateHandler( mock, allowUrls: false ); + + JsonElement parameters = Serialize( new SendWebhookParameters { + Url = "https://example.com/webhook", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// Verifies the Action property returns the correct name. + [TestMethod] + public void SendWebhook_ActionProperty_IsCorrect( ) { + SendWebhookHandler handler = CreateHandler( MockHttpMessageHandler.Ok( ) ); + Assert.AreEqual( "SendWebhook", handler.Action ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/StartProcessHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/StartProcessHandlerTests.cs index 8ca7439..514856d 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/Actions/StartProcessHandlerTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/StartProcessHandlerTests.cs @@ -76,7 +76,7 @@ public async Task StartProcess_EchoCommand_Succeeds( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -107,7 +107,7 @@ public async Task StartProcess_NonZeroExit_ReturnsFailure( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -138,7 +138,7 @@ public async Task StartProcess_FireAndForget_ReturnsImmediately( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -160,7 +160,7 @@ public async Task StartProcess_BareExecutable_SkipsPathValidation( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -192,7 +192,7 @@ public async Task StartProcess_Timeout_KillsProcess( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -224,7 +224,7 @@ public async Task StartProcess_CapturesOutput( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); _channel.Writer.Complete( ); diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/StopProcessHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/StopProcessHandlerTests.cs index ffdc1be..6a39ff3 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/Actions/StopProcessHandlerTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/StopProcessHandlerTests.cs @@ -60,7 +60,7 @@ public async Task StopProcess_NonexistentName_ReturnsFailure( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -97,7 +97,7 @@ public async Task StopProcess_ByPid_StopsProcess( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -125,7 +125,7 @@ public async Task StopProcess_InvalidPid_ReturnsFailure( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/TestConnectionHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/TestConnectionHandlerTests.cs new file mode 100644 index 0000000..69d5d60 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/TestConnectionHandlerTests.cs @@ -0,0 +1,206 @@ +using System.Net; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Tests TCP and HTTP connectivity checking with mock infrastructure. +/// +[TestClass] +public class TestConnectionHandlerTests { + + /// Unbounded channel for capturing messages. + private Channel _channel = null!; + + /// Gets or sets the MSTest test context. + public TestContext TestContext { get; set; } = null!; + + /// Sets up the output channel. + [TestInitialize] + public void TestInit( ) { + _channel = Channel.CreateUnbounded( ); + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + private static TestConnectionHandler CreateHandler( + MockHttpMessageHandler mockHttp, + bool allowUrls = true + ) => + new( + allowUrls ? TestUrlValidator.AllowAll : TestUrlValidator.DenyAll, + new TestHttpClientFactory( mockHttp ), + NullLogger.Instance + ); + + /// Verifies HTTP mode returns reachable when server responds with 200. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task TestConnection_Http_Reachable( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( ); + TestConnectionHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new TestConnectionParameters { + Host = "example.com", + Port = 80, + Protocol = ConnectionProtocol.Http, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsNotNull( result.OutputVariableValue ); + + using JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue ); + Assert.IsTrue( doc.RootElement.GetProperty( "reachable" ).GetBoolean( ) ); + Assert.AreEqual( 200, doc.RootElement.GetProperty( "statusCode" ).GetInt32( ) ); + } + + /// Verifies HTTPS mode returns reachable with status code. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task TestConnection_Https_Reachable( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( ); + TestConnectionHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new TestConnectionParameters { + Host = "example.com", + Port = 443, + Protocol = ConnectionProtocol.Https, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + using JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.IsTrue( doc.RootElement.GetProperty( "reachable" ).GetBoolean( ) ); + } + + /// Verifies HTTP mode with expected status code mismatch sets reachable=false. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task TestConnection_Http_ExpectedStatusMismatch_NotReachable( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.WithStatus( HttpStatusCode.NotFound ); + TestConnectionHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new TestConnectionParameters { + Host = "example.com", + Port = 80, + Protocol = ConnectionProtocol.Http, + ExpectedStatusCode = 200, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); // action itself succeeds + using JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.IsFalse( doc.RootElement.GetProperty( "reachable" ).GetBoolean( ) ); + Assert.AreEqual( 404, doc.RootElement.GetProperty( "statusCode" ).GetInt32( ) ); + } + + /// Verifies HTTP mode with matching expected status code sets reachable=true. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task TestConnection_Http_ExpectedStatusMatch_Reachable( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.WithStatus( HttpStatusCode.NotFound ); + TestConnectionHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new TestConnectionParameters { + Host = "example.com", + Port = 80, + Protocol = ConnectionProtocol.Http, + ExpectedStatusCode = 404, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + using JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.IsTrue( doc.RootElement.GetProperty( "reachable" ).GetBoolean( ) ); + } + + /// Verifies that HTTP mode captures connection errors. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task TestConnection_Http_ConnectionError_NotReachable( ) { + MockHttpMessageHandler mock = new( ( _, _ ) => + throw new HttpRequestException( "Connection refused" ) ); + TestConnectionHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new TestConnectionParameters { + Host = "example.com", + Port = 80, + Protocol = ConnectionProtocol.Http, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); // action itself succeeds + using JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.IsFalse( doc.RootElement.GetProperty( "reachable" ).GetBoolean( ) ); + Assert.IsNotNull( doc.RootElement.GetProperty( "error" ).GetString( ) ); + } + + /// Verifies denied URL causes action failure. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task TestConnection_DeniedUrl_Fails( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( ); + TestConnectionHandler handler = CreateHandler( mock, allowUrls: false ); + + JsonElement parameters = Serialize( new TestConnectionParameters { + Host = "example.com", + Port = 80, + Protocol = ConnectionProtocol.Http, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// Verifies the output contains timing information. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task TestConnection_OutputContainsElapsedMs( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( ); + TestConnectionHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new TestConnectionParameters { + Host = "example.com", + Port = 443, + Protocol = ConnectionProtocol.Https, + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + using JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.IsTrue( doc.RootElement.TryGetProperty( "elapsedMs", out JsonElement elapsed ) ); + Assert.IsGreaterThanOrEqualTo( 0L, elapsed.GetInt64( ) ); + } + + /// Verifies the Action property returns the correct name. + [TestMethod] + public void TestConnection_ActionProperty_IsCorrect( ) { + TestConnectionHandler handler = CreateHandler( MockHttpMessageHandler.Ok( ) ); + Assert.AreEqual( "TestConnection", handler.Action ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/TestExistsHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/TestExistsHandlerTests.cs index 47dba90..4f9676b 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/Actions/TestExistsHandlerTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/TestExistsHandlerTests.cs @@ -93,7 +93,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -114,7 +114,7 @@ public async Task FileNotExists_ReturnsFailure( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -136,7 +136,7 @@ public async Task DirectoryExists_ReturnsSuccess( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -157,7 +157,7 @@ public async Task DirectoryNotExists_ReturnsFailure( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); @@ -183,7 +183,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -205,7 +205,7 @@ public async Task AnyType_DirectoryExists_ReturnsSuccess( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -229,7 +229,7 @@ public async Task FileType_OnDirectory_ReturnsFailure( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsFalse( result.Success ); diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/TransformJsonHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/TransformJsonHandlerTests.cs new file mode 100644 index 0000000..6299e71 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/TransformJsonHandlerTests.cs @@ -0,0 +1,727 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Validates dual-mode input (file vs. variable), JSON Pointer path navigation, +/// and all four operation types: Extract, Set, Delete, Merge. +/// +[TestClass] +public class TransformJsonHandlerTests { + + /// Handler under test. + private TransformJsonHandler _handler = null!; + /// Captures messages. + private Channel _channel = null!; + /// Temp directory for file I/O tests. + private string _tempDir = null!; + + /// MSTest context. + public TestContext TestContext { get; set; } = null!; + + /// Creates handler, channel, and temp directory for each test. + [TestInitialize] + public void TestInit( ) { + _handler = new TransformJsonHandler( + TestFilePathResolver.AllowAll, + NullLogger.Instance + ); + _channel = Channel.CreateUnbounded( ); + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-transformjson-{Guid.NewGuid( ):N}" ); + _ = Directory.CreateDirectory( _tempDir ); + } + + /// Removes the temp directory after each test. + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + // ══════════════════════════════════════════════════════════════════════════════ + // ── Input Resolution ───────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// Variable input is used when no InputPath is set. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task TransformJson_VariableInput_Succeeds( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/name" }], + } ); + string input = """{"name":"Alice","age":30}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: input, + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "\"Alice\"", result.OutputVariableValue ); + } + + /// File input takes precedence over variable input. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task TransformJson_FileInputTakesPrecedence( ) { + string filePath = Path.Combine( _tempDir, "input.json" ); + await File.WriteAllTextAsync( filePath, """{"source":"file"}""", CancellationToken.None ); + + JsonElement parameters = Serialize( new TransformJsonParameters { + InputPath = filePath, + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/source" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"source":"variable"}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "\"file\"", result.OutputVariableValue ); + } + + /// Fails with descriptive error when neither input source is available. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task TransformJson_NoInput_Fails( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/x" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, + cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "neither", result.Exception.Message ); + } + + /// Fails with descriptive error when input is not valid JSON. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task TransformJson_InvalidJsonInput_Fails( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/x" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: "not-json{{{", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "not valid JSON", result.Exception.Message ); + } + + /// Fails when InputPath file does not exist. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task TransformJson_InputFileNotFound_Fails( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + InputPath = Path.Combine( _tempDir, "nope.json" ), + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/x" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, + cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "not found", result.Exception.Message ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── Extract ────────────────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// Extract a top-level string property. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Extract_TopLevelProperty( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/name" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"name":"Bob","age":25}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "\"Bob\"", result.OutputVariableValue ); + } + + /// Extract a nested property using JSON Pointer. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Extract_NestedProperty( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/address/city" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, + inputVariableValue: """{"address":{"city":"Seattle","state":"WA"}}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "\"Seattle\"", result.OutputVariableValue ); + } + + /// Extract an array element by index. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Extract_ArrayElement( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/items/1" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"items":["a","b","c"]}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "\"b\"", result.OutputVariableValue ); + } + + /// Extract a non-existent path returns null. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Extract_NonExistentPath_ReturnsNull( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/missing" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"name":"Alice"}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "null", result.OutputVariableValue ); + } + + /// Extract root (empty pointer) returns the full document. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Extract_RootPointer_ReturnsFullDocument( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "" }], + } ); + string input = """{"x":1}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: input, + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsNotNull( result.OutputVariableValue ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue ); + Assert.AreEqual( 1, doc.RootElement.GetProperty( "x" ).GetInt32( ) ); + } + + /// Extract using $. convenience syntax. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Extract_DollarDotSyntax( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "$.name" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"name":"Charlie"}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "\"Charlie\"", result.OutputVariableValue ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── Set ────────────────────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// Set an existing property to a new value. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Set_ExistingProperty( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Set, Path = "/name", Value = "\"Eve\"" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"name":"Alice"}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.AreEqual( "Eve", doc.RootElement.GetProperty( "name" ).GetString( ) ); + } + + /// Set a new property on an existing object. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Set_NewProperty( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Set, Path = "/email", Value = "\"a@b.com\"" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"name":"Alice"}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.AreEqual( "a@b.com", doc.RootElement.GetProperty( "email" ).GetString( ) ); + Assert.AreEqual( "Alice", doc.RootElement.GetProperty( "name" ).GetString( ) ); + } + + /// Set creates intermediate objects for nested paths. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Set_CreatesIntermediateObjects( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Set, Path = "/address/city", Value = "\"Portland\"" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.AreEqual( "Portland", doc.RootElement.GetProperty( "address" ).GetProperty( "city" ).GetString( ) ); + } + + /// Set array element by index. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Set_ArrayElement( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Set, Path = "/items/1", Value = "\"replaced\"" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"items":["a","b","c"]}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.AreEqual( "replaced", doc.RootElement.GetProperty( "items" )[1].GetString( ) ); + } + + /// Set fails when Value is null. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Set_NullValue_Fails( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Set, Path = "/x" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "Value is required", result.Exception.Message ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── Delete ─────────────────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// Delete removes an existing property. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Delete_ExistingProperty( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Delete, Path = "/age" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"name":"Alice","age":30}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.AreEqual( "Alice", doc.RootElement.GetProperty( "name" ).GetString( ) ); + Assert.IsFalse( doc.RootElement.TryGetProperty( "age", out _ ) ); + } + + /// Delete a non-existent path succeeds silently. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Delete_NonExistentPath_SilentSuccess( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Delete, Path = "/missing" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"name":"Alice"}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.AreEqual( "Alice", doc.RootElement.GetProperty( "name" ).GetString( ) ); + } + + /// Delete a nested property. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Delete_NestedProperty( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Delete, Path = "/address/city" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, + inputVariableValue: """{"address":{"city":"Seattle","state":"WA"}}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + JsonElement addr = doc.RootElement.GetProperty( "address" ); + Assert.IsFalse( addr.TryGetProperty( "city", out _ ) ); + Assert.AreEqual( "WA", addr.GetProperty( "state" ).GetString( ) ); + } + + /// Delete an array element by index. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Delete_ArrayElement( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Delete, Path = "/items/1" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"items":["a","b","c"]}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + JsonElement items = doc.RootElement.GetProperty( "items" ); + Assert.AreEqual( 2, items.GetArrayLength( ) ); + Assert.AreEqual( "a", items[0].GetString( ) ); + Assert.AreEqual( "c", items[1].GetString( ) ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── Merge ──────────────────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// Merge into root object adds/overrides properties. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Merge_IntoRoot( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { + Type = JsonTransformType.Merge, Path = "", + Value = """{"age":31,"email":"a@b.com"}""" + }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"name":"Alice","age":30}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.AreEqual( "Alice", doc.RootElement.GetProperty( "name" ).GetString( ) ); + Assert.AreEqual( 31, doc.RootElement.GetProperty( "age" ).GetInt32( ) ); + Assert.AreEqual( "a@b.com", doc.RootElement.GetProperty( "email" ).GetString( ) ); + } + + /// Merge into a nested object. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Merge_IntoNestedObject( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { + Type = JsonTransformType.Merge, Path = "/address", + Value = """{"zip":"98101"}""" + }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, + inputVariableValue: """{"address":{"city":"Seattle"}}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + JsonElement addr = doc.RootElement.GetProperty( "address" ); + Assert.AreEqual( "Seattle", addr.GetProperty( "city" ).GetString( ) ); + Assert.AreEqual( "98101", addr.GetProperty( "zip" ).GetString( ) ); + } + + /// Merge into a non-object target fails. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Merge_NonObjectTarget_Fails( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { + Type = JsonTransformType.Merge, Path = "/name", + Value = """{"x":1}""" + }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"name":"Alice"}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "not a JSON object", result.Exception.Message ); + } + + /// Merge with null Value fails. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Merge_NullValue_Fails( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { + Type = JsonTransformType.Merge, Path = "" + }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "Value is required", result.Exception.Message ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── Operation Sequencing ───────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// Multiple operations applied in order: extract → set. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task OperationSequencing_ExtractThenSet( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [ + new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/settings" }, + new JsonTransformOperation { Type = JsonTransformType.Set, Path = "/theme", Value = "\"dark\"" }, + ], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, + inputVariableValue: """{"settings":{"theme":"light","lang":"en"}}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.AreEqual( "dark", doc.RootElement.GetProperty( "theme" ).GetString( ) ); + Assert.AreEqual( "en", doc.RootElement.GetProperty( "lang" ).GetString( ) ); + } + + /// Set then delete in sequence. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task OperationSequencing_SetThenDelete( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [ + new JsonTransformOperation { Type = JsonTransformType.Set, Path = "/temp", Value = "true" }, + new JsonTransformOperation { Type = JsonTransformType.Delete, Path = "/old" }, + ], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"old":"data","keep":"me"}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.IsTrue( doc.RootElement.GetProperty( "temp" ).GetBoolean( ) ); + Assert.AreEqual( "me", doc.RootElement.GetProperty( "keep" ).GetString( ) ); + Assert.IsFalse( doc.RootElement.TryGetProperty( "old", out _ ) ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── Output ─────────────────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// OutputPath writes file AND populates OutputVariableValue. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task OutputPath_WritesFileAndVariable( ) { + string outputFile = Path.Combine( _tempDir, "out.json" ); + JsonElement parameters = Serialize( new TransformJsonParameters { + OutputPath = outputFile, + Operations = [new JsonTransformOperation { Type = JsonTransformType.Set, Path = "/x", Value = "42" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsNotNull( result.OutputVariableValue ); + + // Verify file was written + Assert.IsTrue( File.Exists( outputFile ) ); + string fileContent = await File.ReadAllTextAsync( outputFile, CancellationToken.None ); + JsonDocument fileParsed = JsonDocument.Parse( fileContent ); + Assert.AreEqual( 42, fileParsed.RootElement.GetProperty( "x" ).GetInt32( ) ); + + // Verify variable matches + JsonDocument varParsed = JsonDocument.Parse( result.OutputVariableValue ); + Assert.AreEqual( 42, varParsed.RootElement.GetProperty( "x" ).GetInt32( ) ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── Path Validation ────────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// Denied InputPath fails via IFilePathResolver. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DeniedInputPath_Fails( ) { + TransformJsonHandler deniedHandler = new( + TestFilePathResolver.DenyAll, + NullLogger.Instance ); + + JsonElement parameters = Serialize( new TransformJsonParameters { + InputPath = "/some/path.json", + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/x" }], + } ); + + ActionOperatorResult result = await deniedHandler.ExecuteAsync( + parameters, _channel.Writer, + cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// Denied OutputPath fails via IFilePathResolver. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task DeniedOutputPath_Fails( ) { + TransformJsonHandler deniedHandler = new( + TestFilePathResolver.DenyAll, + NullLogger.Instance ); + + JsonElement parameters = Serialize( new TransformJsonParameters { + OutputPath = "/some/output.json", + Operations = [new JsonTransformOperation { Type = JsonTransformType.Set, Path = "/x", Value = "1" }], + } ); + + ActionOperatorResult result = await deniedHandler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── JSON Pointer Edge Cases ────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// Tilde escaping: ~0 → ~ and ~1 → /. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Pointer_TildeEscaping( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/a~1b" }], + } ); + + // The property name is literally "a/b" + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"a/b":"found"}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "\"found\"", result.OutputVariableValue ); + } + + /// ~0 unescapes to ~. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Pointer_Tilde0Escaping( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "/a~0b" }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{"a~b":"found"}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "\"found\"", result.OutputVariableValue ); + } + + /// Empty operations array fails. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task EmptyOperations_Fails( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: """{}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + Assert.IsNotNull( result.Exception ); + Assert.Contains( "at least one operation", result.Exception.Message ); + } + + /// $ alone references root. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Pointer_DollarAlone_ReferencesRoot( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { Type = JsonTransformType.Extract, Path = "$" }], + } ); + string input = """{"x":1}"""; + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, inputVariableValue: input, + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + Assert.AreEqual( 1, doc.RootElement.GetProperty( "x" ).GetInt32( ) ); + } + + /// Deep merge recursively merges nested objects. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task Merge_DeepRecursive( ) { + JsonElement parameters = Serialize( new TransformJsonParameters { + Operations = [new JsonTransformOperation { + Type = JsonTransformType.Merge, Path = "", + Value = """{"a":{"y":2}}""" + }], + } ); + + ActionOperatorResult result = await _handler.ExecuteAsync( + parameters, _channel.Writer, + inputVariableValue: """{"a":{"x":1}}""", + cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue! ); + JsonElement a = doc.RootElement.GetProperty( "a" ); + Assert.AreEqual( 1, a.GetProperty( "x" ).GetInt32( ) ); + Assert.AreEqual( 2, a.GetProperty( "y" ).GetInt32( ) ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/UploadFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/UploadFileHandlerTests.cs new file mode 100644 index 0000000..3011623 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/UploadFileHandlerTests.cs @@ -0,0 +1,210 @@ +using System.Net; +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Uses to avoid real HTTP traffic. +/// +[TestClass] +public class UploadFileHandlerTests { + + /// Temporary directory for source file tests. + private string _tempDir = null!; + /// Unbounded channel for capturing messages. + private Channel _channel = null!; + + /// Gets or sets the MSTest test context. + public TestContext TestContext { get; set; } = null!; + + /// Sets up the temp directory and output channel. + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( Path.GetTempPath( ), $"werkr-test-{Guid.NewGuid( )}" ); + _ = Directory.CreateDirectory( _tempDir ); + _channel = Channel.CreateUnbounded( ); + } + + /// Cleans up the temp directory. + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( _tempDir, recursive: true ); + } + } + + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + private static UploadFileHandler CreateHandler( + MockHttpMessageHandler mockHttp, + bool allowUrls = true, + bool allowFiles = true + ) => + new( + allowUrls ? TestUrlValidator.AllowAll : TestUrlValidator.DenyAll, + new TestHttpClientFactory( mockHttp ), + allowFiles ? TestFilePathResolver.AllowAll : TestFilePathResolver.DenyAll, + NullLogger.Instance + ); + + /// Verifies a file upload sends multipart form data. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task UploadFile_Success_SendsMultipart( ) { + string? capturedContentType = null; + MockHttpMessageHandler mock = new( ( req, _ ) => { + capturedContentType = req.Content?.Headers.ContentType?.MediaType; + return Task.FromResult( new HttpResponseMessage( HttpStatusCode.OK ) { + Content = new StringContent( "{\"uploaded\":true}" ), + } ); + } ); + UploadFileHandler handler = CreateHandler( mock ); + + string srcFile = Path.Combine( _tempDir, "upload.txt" ); + await File.WriteAllTextAsync( srcFile, "upload-content", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new UploadFileParameters { + FilePath = srcFile, + Url = "https://example.com/upload", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "multipart/form-data", capturedContentType ); + } + + /// Verifies output variable contains status code and metadata. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task UploadFile_OutputVariable_ContainsMetadata( ) { + MockHttpMessageHandler mock = new( new HttpResponseMessage( HttpStatusCode.Created ) { + Content = new StringContent( "{\"id\":99}" ), + } ); + UploadFileHandler handler = CreateHandler( mock ); + + string srcFile = Path.Combine( _tempDir, "data.bin" ); + await File.WriteAllTextAsync( srcFile, "binary-data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new UploadFileParameters { + FilePath = srcFile, + Url = "https://example.com/upload", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.IsNotNull( result.OutputVariableValue ); + + using JsonDocument doc = JsonDocument.Parse( result.OutputVariableValue ); + Assert.AreEqual( 201, doc.RootElement.GetProperty( "statusCode" ).GetInt32( ) ); + Assert.AreEqual( "data.bin", doc.RootElement.GetProperty( "fileName" ).GetString( ) ); + Assert.IsGreaterThan( 0L, doc.RootElement.GetProperty( "fileSize" ).GetInt64( ) ); + } + + /// Verifies that a missing source file causes failure. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task UploadFile_MissingFile_Fails( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( ); + UploadFileHandler handler = CreateHandler( mock ); + + JsonElement parameters = Serialize( new UploadFileParameters { + FilePath = Path.Combine( _tempDir, "nonexistent.txt" ), + Url = "https://example.com/upload", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// Verifies that a denied URL causes failure. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task UploadFile_DeniedUrl_Fails( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( ); + UploadFileHandler handler = CreateHandler( mock, allowUrls: false ); + + string srcFile = Path.Combine( _tempDir, "file.txt" ); + await File.WriteAllTextAsync( srcFile, "data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new UploadFileParameters { + FilePath = srcFile, + Url = "https://example.com/upload", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// Verifies that a denied file path causes failure. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task UploadFile_DeniedFilePath_Fails( ) { + MockHttpMessageHandler mock = MockHttpMessageHandler.Ok( ); + UploadFileHandler handler = CreateHandler( mock, allowFiles: false ); + + JsonElement parameters = Serialize( new UploadFileParameters { + FilePath = "/denied/file.txt", + Url = "https://example.com/upload", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsFalse( result.Success ); + } + + /// Verifies the custom HTTP method is used. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task UploadFile_CustomMethod_Used( ) { + string? capturedMethod = null; + MockHttpMessageHandler mock = new( ( req, _ ) => { + capturedMethod = req.Method.Method; + return Task.FromResult( new HttpResponseMessage( HttpStatusCode.OK ) { + Content = new StringContent( "ok" ), + } ); + } ); + UploadFileHandler handler = CreateHandler( mock ); + + string srcFile = Path.Combine( _tempDir, "file.txt" ); + await File.WriteAllTextAsync( srcFile, "data", TestContext.CancellationToken ); + + JsonElement parameters = Serialize( new UploadFileParameters { + FilePath = srcFile, + Url = "https://example.com/upload", + Method = "PUT", + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, _channel.Writer, cancellationToken: TestContext.CancellationToken ); + + Assert.IsTrue( result.Success ); + Assert.AreEqual( "PUT", capturedMethod ); + } + + /// Verifies the Action property returns the correct name. + [TestMethod] + public void UploadFile_ActionProperty_IsCorrect( ) { + UploadFileHandler handler = CreateHandler( MockHttpMessageHandler.Ok( ) ); + Assert.AreEqual( "UploadFile", handler.Action ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/WatchFileHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/WatchFileHandlerTests.cs new file mode 100644 index 0000000..a4bebe8 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/WatchFileHandlerTests.cs @@ -0,0 +1,356 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Agent.Operators.Actions; +using Werkr.Common.Models; +using Werkr.Common.Models.Actions; +using Werkr.Core.Communication; +using Werkr.Core.Operators; +using Werkr.Tests.Agent.Helpers; + +namespace Werkr.Tests.Agent.Operators.Actions; + +/// +/// Unit tests for the action handler. +/// Validates FSW-mode detection, polling-mode detection, stability checks, +/// timeout behavior, and cancellation. +/// +[TestClass] +public class WatchFileHandlerTests { + + /// + /// Temporary directory created for each test and cleaned up afterward. + /// + private string _tempDir = null!; + /// + /// Unbounded channel used to capture messages. + /// + private Channel _channel = null!; + + /// + /// Gets or sets the MSTest for the current test run. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Creates a unique temporary directory and an unbounded output channel. + /// + [TestInitialize] + public void TestInit( ) { + _tempDir = Path.Combine( + Path.GetTempPath( ), + $"werkr-test-{Guid.NewGuid( )}" + ); + _ = Directory.CreateDirectory( _tempDir ); + _channel = Channel.CreateUnbounded( ); + } + + /// + /// Deletes the temporary directory and all its contents. + /// + [TestCleanup] + public void TestCleanup( ) { + if (Directory.Exists( _tempDir )) { + Directory.Delete( + _tempDir, + recursive: true + ); + } + } + + /// + /// Serializes a value to a using the shared test serializer. + /// + private static JsonElement Serialize( T value ) => + TestActionDescriptor.Serialize( value ); + + /// + /// Verifies that a pre-existing matching file is detected immediately in FSW mode. + /// + [TestMethod] + [Timeout( 30_000, CooperativeCancellation = true )] + public async Task WatchFile_PreExistingFile_FswMode_Succeeds( ) { + // Create file before starting the watch + string filePath = Path.Combine( _tempDir, "data.csv" ); + await File.WriteAllTextAsync( filePath, "content", TestContext.CancellationToken ); + + WatchFileHandler handler = new( + TestFilePathResolver.AllowAll, + NullLogger.Instance, + TimeProvider.System + ); + + JsonElement parameters = Serialize( new WatchFileParameters { + Directory = _tempDir, + Pattern = "*.csv", + StabilitySeconds = 1, + TimeoutSeconds = 10, + PollIntervalMs = 100 + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + } + + /// + /// Verifies that a file created after watch starts is detected in FSW mode. + /// + [TestMethod] + [Timeout( 30_000, CooperativeCancellation = true )] + public async Task WatchFile_NewFile_FswMode_Succeeds( ) { + WatchFileHandler handler = new( + TestFilePathResolver.AllowAll, + NullLogger.Instance, + TimeProvider.System + ); + + JsonElement parameters = Serialize( new WatchFileParameters { + Directory = _tempDir, + Pattern = "*.csv", + StabilitySeconds = 1, + TimeoutSeconds = 10, + PollIntervalMs = 100 + } ); + + // Start watching, then create the file shortly after + Task watchTask = handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + await Task.Delay( 200, TestContext.CancellationToken ); + string filePath = Path.Combine( _tempDir, "report.csv" ); + await File.WriteAllTextAsync( filePath, "content", TestContext.CancellationToken ); + + ActionOperatorResult result = await watchTask; + + Assert.IsTrue( result.Success ); + } + + /// + /// Verifies that polling mode detects a pre-existing file. + /// + [TestMethod] + [Timeout( 30_000, CooperativeCancellation = true )] + public async Task WatchFile_PreExistingFile_PollingMode_Succeeds( ) { + string filePath = Path.Combine( _tempDir, "data.csv" ); + await File.WriteAllTextAsync( filePath, "content", TestContext.CancellationToken ); + + WatchFileHandler handler = new( + TestFilePathResolver.AllowAll, + NullLogger.Instance, + TimeProvider.System + ); + + JsonElement parameters = Serialize( new WatchFileParameters { + Directory = _tempDir, + Pattern = "*.csv", + StabilitySeconds = 1, + TimeoutSeconds = 10, + PollIntervalMs = 100, + UsePolling = true + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + } + + /// + /// Verifies that FailOnTimeout returns failure when no file appears. + /// + [TestMethod] + [Timeout( 30_000, CooperativeCancellation = true )] + public async Task WatchFile_Timeout_FailOnTimeout_ReturnsFailure( ) { + WatchFileHandler handler = new( + TestFilePathResolver.AllowAll, + NullLogger.Instance, + TimeProvider.System + ); + + JsonElement parameters = Serialize( new WatchFileParameters { + Directory = _tempDir, + Pattern = "*.csv", + StabilitySeconds = 1, + TimeoutSeconds = 2, + PollIntervalMs = 100, + UsePolling = true, + Mode = WatchFileMode.FailOnTimeout + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// + /// Verifies that ExitQuietly returns success when no file appears within timeout. + /// + [TestMethod] + [Timeout( 30_000, CooperativeCancellation = true )] + public async Task WatchFile_Timeout_ExitQuietly_ReturnsSuccess( ) { + WatchFileHandler handler = new( + TestFilePathResolver.AllowAll, + NullLogger.Instance, + TimeProvider.System + ); + + JsonElement parameters = Serialize( new WatchFileParameters { + Directory = _tempDir, + Pattern = "*.csv", + StabilitySeconds = 1, + TimeoutSeconds = 2, + PollIntervalMs = 100, + UsePolling = true, + Mode = WatchFileMode.ExitQuietly + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsTrue( result.Success ); + } + + /// + /// Verifies that a non-matching pattern does not trigger detection. + /// + [TestMethod] + [Timeout( 30_000, CooperativeCancellation = true )] + public async Task WatchFile_NonMatchingPattern_TimesOut( ) { + string filePath = Path.Combine( _tempDir, "data.txt" ); + await File.WriteAllTextAsync( filePath, "content", TestContext.CancellationToken ); + + WatchFileHandler handler = new( + TestFilePathResolver.AllowAll, + NullLogger.Instance, + TimeProvider.System + ); + + JsonElement parameters = Serialize( new WatchFileParameters { + Directory = _tempDir, + Pattern = "*.csv", // won't match .txt file + StabilitySeconds = 1, + TimeoutSeconds = 2, + PollIntervalMs = 100, + UsePolling = true, + Mode = WatchFileMode.FailOnTimeout + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + } + + /// + /// Verifies that cancellation during a watch propagates as OperationCanceledException. + /// + [TestMethod] + [Timeout( 30_000, CooperativeCancellation = true )] + public async Task WatchFile_Cancelled_ThrowsOperationCanceled( ) { + WatchFileHandler handler = new( + TestFilePathResolver.AllowAll, + NullLogger.Instance, + TimeProvider.System + ); + + JsonElement parameters = Serialize( new WatchFileParameters { + Directory = _tempDir, + Pattern = "*.csv", + StabilitySeconds = 1, + TimeoutSeconds = 300, + PollIntervalMs = 100, + UsePolling = true + } ); + + using CancellationTokenSource cts = new( ); + + Task task = handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: cts.Token + ); + + await Task.Delay( 200, TestContext.CancellationToken ); + await cts.CancelAsync( ); + + _ = await Assert.ThrowsExactlyAsync( ( ) => task ); + } + + /// + /// Verifies that a denied directory path returns failure. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WatchFile_DeniedPath_ReturnsFailure( ) { + WatchFileHandler handler = new( + TestFilePathResolver.DenyAll, + NullLogger.Instance, + TimeProvider.System + ); + + JsonElement parameters = Serialize( new WatchFileParameters { + Directory = "/some/path", + Pattern = "*.csv" + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } + + /// + /// Verifies that a non-existent directory returns failure. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WatchFile_NonExistentDirectory_ReturnsFailure( ) { + string missingDir = Path.Combine( _tempDir, "does-not-exist" ); + + WatchFileHandler handler = new( + TestFilePathResolver.AllowAll, + NullLogger.Instance, + TimeProvider.System + ); + + JsonElement parameters = Serialize( new WatchFileParameters { + Directory = missingDir, + Pattern = "*.csv" + } ); + + ActionOperatorResult result = await handler.ExecuteAsync( + parameters, + _channel.Writer, + cancellationToken: TestContext.CancellationToken + ); + + Assert.IsFalse( result.Success ); + _ = Assert.IsInstanceOfType( result.Exception ); + } +} diff --git a/src/Test/Werkr.Tests.Agent/Operators/Actions/WriteContentHandlerTests.cs b/src/Test/Werkr.Tests.Agent/Operators/Actions/WriteContentHandlerTests.cs index 7f1261c..5292204 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/Actions/WriteContentHandlerTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/Actions/WriteContentHandlerTests.cs @@ -86,7 +86,7 @@ public async Task WriteContent_NewFile_Succeeds( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -119,7 +119,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -154,7 +154,7 @@ await File.WriteAllTextAsync( ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); @@ -184,7 +184,7 @@ public async Task WriteContent_CustomEncoding( ) { ActionOperatorResult result = await _handler.ExecuteAsync( parameters, _channel.Writer, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsTrue( result.Success ); diff --git a/src/Test/Werkr.Tests.Agent/Operators/PSHostTests.cs b/src/Test/Werkr.Tests.Agent/Operators/PSHostTests.cs index 31a5b76..eb06e62 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/PSHostTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/PSHostTests.cs @@ -58,7 +58,7 @@ public async Task FormatTable_ProducesColumnarOutput( ) { OperatorExecution execution = op.RunCommand( "Get-ChildItem -Path / -Force -ErrorAction SilentlyContinue " + "| Select-Object -First 3 | Format-Table -AutoSize", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = await CollectOutputAsync( @@ -105,7 +105,7 @@ public async Task FormatTable_GetProcess_ProducesFormattedTable( ) { PwshOperator op = CreateOperator( ); OperatorExecution execution = op.RunCommand( "Get-Process | Select-Object -First 5 | Format-Table -Property Id, ProcessName -AutoSize", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = await CollectOutputAsync( @@ -143,7 +143,7 @@ public async Task FormatList_ProducesPropertyList( ) { PwshOperator op = CreateOperator( ); OperatorExecution execution = op.RunCommand( "Get-Process | Select-Object -First 1 | Format-List -Property Id, ProcessName", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = await CollectOutputAsync( @@ -180,7 +180,7 @@ public async Task WriteHost_CapturesText( ) { PwshOperator op = CreateOperator( ); OperatorExecution execution = op.RunCommand( "Write-Host 'Hello from PSHost'", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = await CollectOutputAsync( @@ -203,7 +203,7 @@ public async Task WriteProgress_CapturesProgressOutput( ) { PwshOperator op = CreateOperator( ); OperatorExecution execution = op.RunCommand( "Write-Progress -Activity 'TestActivity' -Status 'Running' -PercentComplete 50", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = await CollectOutputAsync( @@ -228,7 +228,7 @@ public async Task RawPipeline_RendersIntegers( ) { PwshOperator op = CreateOperator( ); OperatorExecution execution = op.RunCommand( "1..5 | ForEach-Object { $_ }", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = await CollectOutputAsync( @@ -263,7 +263,7 @@ public async Task WriteOutput_CapturedViaSingleFlow( ) { PwshOperator op = CreateOperator( ); OperatorExecution execution = op.RunCommand( "Write-Output 'single-flow-test'", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = await CollectOutputAsync( @@ -289,7 +289,7 @@ public async Task BufferWidth_AffectsFormatting( ) { PwshOperator narrowOp = CreateOperator( bufferWidth: 40 ); OperatorExecution narrowExec = narrowOp.RunCommand( "Get-Process | Select-Object -First 3 | Format-Table -Property Id, ProcessName, CPU", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List narrowOutputs = await CollectOutputAsync( @@ -301,7 +301,7 @@ public async Task BufferWidth_AffectsFormatting( ) { PwshOperator wideOp = CreateOperator( bufferWidth: 200 ); OperatorExecution wideExec = wideOp.RunCommand( "Get-Process | Select-Object -First 3 | Format-Table -Property Id, ProcessName, CPU", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List wideOutputs = await CollectOutputAsync( @@ -364,7 +364,7 @@ public async Task WriteError_CapturedViaHostUI( ) { PwshOperator op = CreateOperator( ); OperatorExecution execution = op.RunCommand( "Write-Error 'pshost-error-test'", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = await CollectOutputAsync( @@ -392,7 +392,7 @@ public async Task WriteWarning_CapturedViaHostUI( ) { PwshOperator op = CreateOperator( ); OperatorExecution execution = op.RunCommand( "Write-Warning 'pshost-warning-test'", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = await CollectOutputAsync( @@ -421,7 +421,7 @@ public async Task HadErrors_StillWorksWithCustomRunspace( ) { // A command that succeeds — HadErrors should be false OperatorExecution successExec = op.RunCommand( "Write-Output 'success'", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); _ = await CollectOutputAsync( successExec, @@ -436,7 +436,7 @@ public async Task HadErrors_StillWorksWithCustomRunspace( ) { // A command that errors — HadErrors should be true OperatorExecution errorExec = op.RunCommand( "Write-Error 'fail'", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); _ = await CollectOutputAsync( errorExec, @@ -459,7 +459,7 @@ public async Task NoDuplicateOutput_WriteHost( ) { PwshOperator op = CreateOperator( ); OperatorExecution execution = op.RunCommand( "Write-Host 'unique-message-42'", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); List outputs = await CollectOutputAsync( diff --git a/src/Test/Werkr.Tests.Agent/Operators/PwshOperatorTests.cs b/src/Test/Werkr.Tests.Agent/Operators/PwshOperatorTests.cs index aedf029..81b6f1d 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/PwshOperatorTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/PwshOperatorTests.cs @@ -55,7 +55,7 @@ public async Task RunCommand_ProducesOutput( ) { OperatorExecution execution = _operator.RunCommand( "Write-Output 'hello'", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { outputs.Add( output ); @@ -81,7 +81,7 @@ public async Task RunCommand_ErrorStream( ) { OperatorExecution execution = _operator.RunCommand( "Write-Error 'fail'", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { outputs.Add( output ); @@ -105,7 +105,7 @@ public async Task RunCommand_MultipleStreams( ) { OperatorExecution execution = _operator.RunCommand( "Write-Output 'out'; Write-Warning 'warn'; Write-Error 'err'", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { outputs.Add( output ); @@ -145,7 +145,7 @@ public async Task RunCommand_Cancellation( ) { try { OperatorExecution execution = _operator.RunCommand( "Start-Sleep 300", - cts.Token + cancellationToken: cts.Token ); await foreach (OperatorOutput output in execution.Output.WithCancellation( cts.Token )) { outputs.Add( output ); @@ -176,7 +176,7 @@ public async Task RunScript_FileNotFound( ) { OperatorExecution execution = _operator.RunScript( "C:\\nonexistent\\fake.ps1", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { outputs.Add( output ); diff --git a/src/Test/Werkr.Tests.Agent/Operators/SystemShellOperatorTests.cs b/src/Test/Werkr.Tests.Agent/Operators/SystemShellOperatorTests.cs index 25451be..dead739 100644 --- a/src/Test/Werkr.Tests.Agent/Operators/SystemShellOperatorTests.cs +++ b/src/Test/Werkr.Tests.Agent/Operators/SystemShellOperatorTests.cs @@ -56,7 +56,7 @@ public async Task RunCommand_StdOut( ) { OperatorExecution execution = _operator.RunCommand( "echo hello", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { outputs.Add( output ); @@ -87,7 +87,7 @@ public async Task RunCommand_NonZeroExitCode( ) { OperatorExecution execution = _operator.RunCommand( command, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { outputs.Add( output ); @@ -120,7 +120,7 @@ public async Task RunCommand_Cancellation( ) { try { OperatorExecution execution = _operator.RunCommand( command, - cts.Token + cancellationToken: cts.Token ); await foreach (OperatorOutput output in execution.Output.WithCancellation( cts.Token )) { outputs.Add( output ); @@ -153,7 +153,7 @@ public async Task RunCommand_CrossPlatform_UsesCorrectShell( ) { OperatorExecution execution = _operator.RunCommand( command, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { outputs.Add( output ); @@ -177,7 +177,7 @@ public async Task RunScript_FileNotFound( ) { OperatorExecution execution = _operator.RunScript( "C:\\nonexistent\\fake.bat", - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { outputs.Add( output ); @@ -204,7 +204,7 @@ public async Task RunCommand_StdErr( ) { OperatorExecution execution = _operator.RunCommand( command, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); await foreach (OperatorOutput output in execution.Output.WithCancellation( TestContext.CancellationToken )) { outputs.Add( output ); diff --git a/src/Test/Werkr.Tests.Agent/Security/PathAllowlistValidatorTests.cs b/src/Test/Werkr.Tests.Agent/Security/PathAllowlistValidatorTests.cs index a4d5080..f7e59da 100644 --- a/src/Test/Werkr.Tests.Agent/Security/PathAllowlistValidatorTests.cs +++ b/src/Test/Werkr.Tests.Agent/Security/PathAllowlistValidatorTests.cs @@ -221,19 +221,15 @@ public void MultiplePrefixes_AnyMatch_Permits( ) { /// /// Simple implementation for tests. /// - private sealed class TestOptionsMonitor : IOptionsMonitor { - - /// - /// Initializes a new instance of the class. - /// - public TestOptionsMonitor( T currentValue ) { - CurrentValue = currentValue; - } + /// + /// Initializes a new instance of the class. + /// + private sealed class TestOptionsMonitor( T currentValue ) : IOptionsMonitor { /// /// Gets the current options value. /// - public T CurrentValue { get; } + public T CurrentValue { get; } = currentValue; /// /// Returns the current value regardless of the supplied . diff --git a/src/Test/Werkr.Tests.Agent/Security/UrlValidatorTests.cs b/src/Test/Werkr.Tests.Agent/Security/UrlValidatorTests.cs new file mode 100644 index 0000000..d7b3816 --- /dev/null +++ b/src/Test/Werkr.Tests.Agent/Security/UrlValidatorTests.cs @@ -0,0 +1,271 @@ +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Werkr.Agent.Security; +using Werkr.Common.Models; + +namespace Werkr.Tests.Agent.Security; + +/// +/// Unit tests for the — validates SSRF protection, +/// private IP rejection, scheme enforcement, allowlist gating, and the +/// EnableNetworkActions configuration gate. +/// +[TestClass] +public class UrlValidatorTests { + + /// MSTest context. + public TestContext TestContext { get; set; } = null!; + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private static UrlValidator CreateValidator( ActionOperatorConfiguration config ) { + TestOptionsMonitor monitor = new( config ); + return new UrlValidator( monitor, NullLogger.Instance ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── EnableNetworkActions Gate ───────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// When disabled, all URLs are rejected. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public void Disabled_RejectsAllUrls( ) { + UrlValidator validator = CreateValidator( new ActionOperatorConfiguration { + EnableNetworkActions = false, + } ); + + UnauthorizedAccessException ex = Assert.ThrowsExactly( + ( ) => validator.ValidateUrl( "https://example.com" ) ); + Assert.Contains( "disabled", ex.Message ); + } + + /// When enabled, a valid public URL is accepted. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public void Enabled_AcceptsPublicUrl( ) { + UrlValidator validator = CreateValidator( new ActionOperatorConfiguration { + EnableNetworkActions = true, + AllowPrivateNetworks = true, // avoid DNS for unit test + } ); + + Uri result = validator.ValidateUrl( "https://example.com/api" ); + Assert.AreEqual( "https://example.com/api", result.OriginalString ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── Scheme Enforcement ─────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// FTP scheme is rejected. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public void FtpScheme_Rejected( ) { + UrlValidator validator = CreateValidator( new ActionOperatorConfiguration { + EnableNetworkActions = true, + AllowPrivateNetworks = true, + } ); + + UnauthorizedAccessException ex = Assert.ThrowsExactly( + ( ) => validator.ValidateUrl( "ftp://example.com/file.txt" ) ); + Assert.Contains( "scheme", ex.Message ); + } + + /// File scheme is rejected. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public void FileScheme_Rejected( ) { + UrlValidator validator = CreateValidator( new ActionOperatorConfiguration { + EnableNetworkActions = true, + AllowPrivateNetworks = true, + } ); + + UnauthorizedAccessException ex = Assert.ThrowsExactly( + ( ) => validator.ValidateUrl( "file:///etc/passwd" ) ); + Assert.Contains( "scheme", ex.Message ); + } + + /// Relative URL is rejected. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public void RelativeUrl_Rejected( ) { + UrlValidator validator = CreateValidator( new ActionOperatorConfiguration { + EnableNetworkActions = true, + AllowPrivateNetworks = true, + } ); + + UnauthorizedAccessException ex = Assert.ThrowsExactly( + ( ) => validator.ValidateUrl( "/api/data" ) ); + // On Unix, Uri.TryCreate parses "/api/data" as file:///api/data (hitting scheme check). + // On Windows, it fails to parse (hitting the absolute-URI check). + bool matchesAbsoluteOrScheme = ex.Message.Contains( "absolute" ) || ex.Message.Contains( "scheme" ); + Assert.IsTrue( matchesAbsoluteOrScheme, $"Expected 'absolute' or 'scheme' in message: {ex.Message}" ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── AllowedUrls Prefix Allowlist ───────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// URL matching an allowed prefix is accepted. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public void AllowedPrefix_Accepted( ) { + UrlValidator validator = CreateValidator( new ActionOperatorConfiguration { + EnableNetworkActions = true, + AllowPrivateNetworks = true, + AllowedUrls = ["https://api.example.com/"], + } ); + + Uri result = validator.ValidateUrl( "https://api.example.com/v1/data" ); + Assert.IsNotNull( result ); + } + + /// URL not matching any allowed prefix is rejected. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public void NonMatchingPrefix_Rejected( ) { + UrlValidator validator = CreateValidator( new ActionOperatorConfiguration { + EnableNetworkActions = true, + AllowPrivateNetworks = true, + AllowedUrls = ["https://api.example.com/"], + } ); + + UnauthorizedAccessException ex = Assert.ThrowsExactly( + ( ) => validator.ValidateUrl( "https://evil.com/attack" ) ); + Assert.Contains( "allowed URL list", ex.Message ); + } + + /// Empty AllowedUrls means all URLs are allowed (when enabled). + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public void EmptyAllowedUrls_AllAllowed( ) { + UrlValidator validator = CreateValidator( new ActionOperatorConfiguration { + EnableNetworkActions = true, + AllowPrivateNetworks = true, + AllowedUrls = [], + } ); + + Uri result = validator.ValidateUrl( "https://anything.example.com/path" ); + Assert.IsNotNull( result ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── Private IP Rejection (SSRF Protection) ─────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// 10.x.x.x is private. + [TestMethod] + public void IsPrivate_10Network( ) { + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "10.0.0.1" ) ) ); + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "10.255.255.255" ) ) ); + } + + /// 172.16-31.x.x is private. + [TestMethod] + public void IsPrivate_172Network( ) { + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "172.16.0.1" ) ) ); + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "172.31.255.255" ) ) ); + Assert.IsFalse( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "172.15.0.1" ) ) ); + Assert.IsFalse( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "172.32.0.1" ) ) ); + } + + /// 192.168.x.x is private. + [TestMethod] + public void IsPrivate_192Network( ) { + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "192.168.0.1" ) ) ); + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "192.168.255.255" ) ) ); + } + + /// 127.x.x.x is loopback. + [TestMethod] + public void IsPrivate_Loopback( ) { + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "127.0.0.1" ) ) ); + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "127.255.255.255" ) ) ); + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Loopback ) ); + } + + /// ::1 IPv6 loopback. + [TestMethod] + public void IsPrivate_IPv6Loopback( ) { + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.IPv6Loopback ) ); + } + + /// 169.254.x.x is link-local. + [TestMethod] + public void IsPrivate_LinkLocal( ) { + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "169.254.0.1" ) ) ); + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "169.254.255.255" ) ) ); + } + + /// 100.64.0.0/10 is carrier-grade NAT (RFC 6598). + [TestMethod] + public void IsPrivate_CarrierGradeNat( ) { + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "100.64.0.1" ) ) ); + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "100.127.255.255" ) ) ); + Assert.IsFalse( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "100.63.255.255" ) ) ); + Assert.IsFalse( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "100.128.0.0" ) ) ); + } + + /// fe80:: is IPv6 link-local. + [TestMethod] + public void IsPrivate_IPv6LinkLocal( ) { + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "fe80::1" ) ) ); + } + + /// fc00::/fd00:: is IPv6 unique local. + [TestMethod] + public void IsPrivate_IPv6UniqueLocal( ) { + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "fc00::1" ) ) ); + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "fd00::1" ) ) ); + } + + /// 0.0.0.0/8 is current-network. + [TestMethod] + public void IsPrivate_ZeroNetwork( ) { + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "0.0.0.0" ) ) ); + Assert.IsTrue( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "0.1.2.3" ) ) ); + } + + /// Public IPs are not private. + [TestMethod] + public void IsNotPrivate_PublicIPs( ) { + Assert.IsFalse( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "8.8.8.8" ) ) ); + Assert.IsFalse( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "93.184.215.14" ) ) ); + Assert.IsFalse( UrlValidator.IsPrivateOrReserved( IPAddress.Parse( "1.1.1.1" ) ) ); + } + + /// AllowPrivateNetworks bypasses private IP check. + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public void AllowPrivateNetworks_BypassesCheck( ) { + UrlValidator validator = CreateValidator( new ActionOperatorConfiguration { + EnableNetworkActions = true, + AllowPrivateNetworks = true, + } ); + + // localhost resolves to 127.0.0.1 — should be allowed when AllowPrivateNetworks is true + Uri result = validator.ValidateUrl( "http://localhost:8080/api" ); + Assert.IsNotNull( result ); + } + + // ══════════════════════════════════════════════════════════════════════════════ + // ── Internal ───────────────────────────────────────────────────────────────── + // ══════════════════════════════════════════════════════════════════════════════ + + /// + /// Simple implementation for tests. + /// + /// Initializes a new instance. + private sealed class TestOptionsMonitor( T currentValue ) : IOptionsMonitor { + + /// Gets the current options value. + public T CurrentValue { get; } = currentValue; + + /// Returns the current value. + public T Get( string? name ) => CurrentValue; + + /// No-op change listener. + public IDisposable? OnChange( Action listener ) => null; + } +} diff --git a/src/Test/Werkr.Tests.Agent/Services/OperatorOutputAdapterTests.cs b/src/Test/Werkr.Tests.Agent/Services/OperatorOutputAdapterTests.cs index 701c154..a15a2fd 100644 --- a/src/Test/Werkr.Tests.Agent/Services/OperatorOutputAdapterTests.cs +++ b/src/Test/Werkr.Tests.Agent/Services/OperatorOutputAdapterTests.cs @@ -1,4 +1,3 @@ -using Werkr.Agent.Protos; using Werkr.Agent.Services; using Werkr.Common.Protos; using Werkr.Core.Communication; diff --git a/src/Test/Werkr.Tests.Agent/Werkr.Tests.Agent.csproj b/src/Test/Werkr.Tests.Agent/Werkr.Tests.Agent.csproj index bdd55f9..5debaf6 100644 --- a/src/Test/Werkr.Tests.Agent/Werkr.Tests.Agent.csproj +++ b/src/Test/Werkr.Tests.Agent/Werkr.Tests.Agent.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Test/Werkr.Tests.Agent/packages.lock.json b/src/Test/Werkr.Tests.Agent/packages.lock.json index 481f2af..ce52fe3 100644 --- a/src/Test/Werkr.Tests.Agent/packages.lock.json +++ b/src/Test/Werkr.Tests.Agent/packages.lock.json @@ -2,6 +2,12 @@ "version": 2, "dependencies": { "net10.0": { + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "Direct", + "requested": "[10.4.0, )", + "resolved": "10.4.0", + "contentHash": "uJ8n9WUEzux9I2CjZh7imGBgZadfwhAKlxuBq7GsNGL8FJF81aHXAYaRMnwW+9EvRFQNytu7xo1ffeuuTncAzg==" + }, "Microsoft.PowerShell.SDK": { "type": "Direct", "requested": "[7.6.0-rc.1, )", @@ -66,6 +72,11 @@ "Microsoft.Testing.Extensions.TrxReport": "2.1.0" } }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, "Grpc.AspNetCore.Server": { "type": "Transitive", "resolved": "2.76.0", @@ -135,8 +146,8 @@ }, "Microsoft.AspNetCore.Metadata": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + "resolved": "10.0.5", + "contentHash": "nXVB1K4RzyhDHKYWLiq3+aJopJZKO5ojFqHV9PZ74fe4VWM/8itoouqsd2KIqSooIwQ13UDNlPQfN2rWr7hc2A==" }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", @@ -177,315 +188,315 @@ }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + "resolved": "10.0.5", + "contentHash": "32c58Rnm47Qvhimawf67KO9PytgPz3QoWye7Abapt0Yocw/JnzMiSNj/pRoIKyn8Jxypkv86zxKD4Q/zNTc0Ag==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + "resolved": "10.0.5", + "contentHash": "ipC4u1VojgEfoIZhtbS2Sx5IluJTP/Jf1hz3yGsxGBgSukYY/CquI6rAjxn5H58CZgVn36qcuPPtNMwZ0AUzMg==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "resolved": "10.0.5", + "contentHash": "uxmFjZEAB/KbsgWFSS4lLqkEHCfXxB2x0UcbiO4e5fCRpFFeTMSx/me6009nYJLu5IKlDwO1POh++P6RilFTDw==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" + "Microsoft.EntityFrameworkCore": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" } }, "Microsoft.EntityFrameworkCore.Sqlite.Core": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", - "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.3", - "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", + "resolved": "10.0.5", + "contentHash": "rVH43bcUyZiMn0SnCpVnvFpl4PFxT4GwmuVVLcT4JL0NtzuHY9ymKV+Llb5cjuJ+6+gEl4eixy2rE8nxOPcBSA==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.5", + "Microsoft.EntityFrameworkCore.Relational": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyModel": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", "SQLitePCLRaw.core": "2.1.11" } }, "Microsoft.Extensions.AmbientMetadata.Application": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==", + "resolved": "10.4.0", + "contentHash": "bovnONzrr/JIc+w343i857rJEb7cQH9UzEjbV5n67agWBEYICGQb8xiqYz5+GoFXp6mKEKLwYCQGttMU1p5yXQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + "Microsoft.Extensions.Configuration": "10.0.4", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.4" } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "resolved": "10.0.5", + "contentHash": "k/QDdQ94/0Shi0KfU+e12m73jfQo+3JpErTtgpZfsCIqkvdEEO0XIx6R+iTbN55rNPaNhOqNY4/sB+jZ8XxVPw==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "resolved": "10.0.5", + "contentHash": "jUEXmkBUPdOS/MP9areK/sbKhdklq9+tEhvwfxGalZVnmyLUO5rrheNNutUBtvbZ7J8ECkG7/r2KXi/IFC06cA==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Caching.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.Compliance.Abstractions": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==", + "resolved": "10.4.0", + "contentHash": "4WkknDbVrHNf+S6fwSt1OAXlGJ/G/QrtJlqx4aNzOLmeT3GRyxpGLZn+Q3UV+RMRAF6FfsijEZBg2ZAW8bTAkg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.4", + "Microsoft.Extensions.ObjectPool": "10.0.4" } }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "resolved": "10.0.5", + "contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "resolved": "10.0.5", + "contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "resolved": "10.0.5", + "contentHash": "OhTr0O79dP49734lLTqVveivVX9sDXxbI/8vjELAZTHXqoN90mdpgTAgwicJED42iaHMCcZcK6Bj+8wNyBikaw==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Physical": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Physical": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "resolved": "10.0.5", + "contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + "resolved": "10.0.5", + "contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA==" }, "Microsoft.Extensions.DependencyInjection.AutoActivation": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==", + "resolved": "10.4.0", + "contentHash": "ksmUG2SFTcXzYdyoLOdeSM/qYLRGN6qbbSzYVkwMK9xsctfR1hYkUayeOpFCMd7L+QSlYX72mK9wxwdgQxyS4g==", "dependencies": { - "Microsoft.Extensions.Hosting.Abstractions": "10.0.3" + "Microsoft.Extensions.Hosting.Abstractions": "10.0.4" } }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + "resolved": "10.0.5", + "contentHash": "xA4kkL+QS6KCAOKz/O0oquHs44Ob8J7zpBCNt3wjkBWDg5aCqfwG8rWWLsg5V86AM0sB849g9JjPjIdksTCIKg==" }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "resolved": "10.0.5", + "contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "resolved": "10.0.5", + "contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" } }, "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==", + "resolved": "10.4.0", + "contentHash": "1/hQmONMWxRTKXuN0pQShQN9QsqIRTS1G4fdmKW0O9phuVZjyzIROQD9Fbfwyn2t+yvP8SzjatGAPX4jDRfgHg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.4" } }, "Microsoft.Extensions.Features": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "djFt1Jt+2uREWWVQiiA4ilYBDtHHY7nK08c5K8xBD9+XFNw3KDVprylrMkH08bZGK3ZHRAkS7JDV9srfLrcm/g==" + "resolved": "10.0.4", + "contentHash": "7to+nkZO+g/GiGQOBzAcrr8HcG8dXETI/hg58fJju0jPO9p/GvNLAis8kMPTBdsjfeTfslBrgFX9Yx1KRnKDww==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "resolved": "10.0.5", + "contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "resolved": "10.0.5", + "contentHash": "dMu5kUPSfol1Rqhmr6nWPSmbFjDe9w6bkoKithG17bWTZA0UyKirTatM5mqYUN3mGpNA0MorlusIoVTh6J7o5g==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + "resolved": "10.0.5", + "contentHash": "mOE3ARusNQR0a5x8YOcnUbfyyXGqoAWQtEc7qFOfNJgruDWQLo39Re+3/Lzj5pLPFuFYj8hN4dgKzaSQDKiOCw==" }, "Microsoft.Extensions.Http": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "M5gWob3dtzlF14oto1lR1ZuSJrR0gGc+obv7zY9LGmX5y3Ndpve29MrrjqJW/m4CFud4TE/KFUuHjjtwxhCO8g==", + "resolved": "10.0.4", + "contentHash": "QRbs+A+WfiGTnV9KFNfWlF+My5euQNZnsvdVMulwRN6C/tEPaF+ZlQfedHoNvFHKLwjQMmqwm4z+TSO9eLvRQw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.4", + "Microsoft.Extensions.Diagnostics": "10.0.4", + "Microsoft.Extensions.Logging": "10.0.4", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4" } }, "Microsoft.Extensions.Http.Diagnostics": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", + "resolved": "10.4.0", + "contentHash": "ybx2QcCWROCnUCbSj/IyHXn1c58brjjHzTTbueKgBl/qHsWk69mu25mjQ3oaMsO1I0+EcS6AhVuhIopL2q3IDw==", "dependencies": { - "Microsoft.Extensions.Http": "10.0.3", - "Microsoft.Extensions.Telemetry": "10.3.0" + "Microsoft.Extensions.Http": "10.0.4", + "Microsoft.Extensions.Telemetry": "10.4.0" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "resolved": "10.0.5", + "contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.Extensions.DependencyInjection": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "PBlaoYeusaxNYyN4WFjzcXWlUDSvLUPxy/e6oP1SONOOYA/oBWT2uBmFGJMV9VTtXiXXxCB39LqlYWbsWE4UKA==", + "resolved": "10.0.4", + "contentHash": "XPXoOpUnWEh0pV7Vl2DK2wj47y73Krhrve5OkPrvGIWdZ4U2r47WO8hEdv+wKn65Kh4pmDdiWm7Ibo5pZX+vig==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + "Microsoft.Extensions.Configuration": "10.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.4", + "Microsoft.Extensions.Configuration.Binder": "10.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.4", + "Microsoft.Extensions.Logging": "10.0.4", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.4" } }, "Microsoft.Extensions.ObjectPool": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "dQKlVXzqflsv5X8iDlAN5YmTL1GcLCrOLKo1s9PNdfjqxeu0S/jmWTfiLGno+8+o1qFL3+VFAH5/ftmypN+sPw==" + "resolved": "10.0.4", + "contentHash": "2pufIFOgNl/yWTOoIC9XgBnO9VxgfAjdRCnVwpE2+ICfcroGnjuEAGzJ5lTdZeAe0HvA31vMBWXtcmGB7TOq3g==" }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "resolved": "10.0.5", + "contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "resolved": "10.0.5", + "contentHash": "BB9uUW3+6Rxu1R97OB1H/13lUF8P2+H1+eDhpZlK30kDh/6E4EKHBUqTp+ilXQmZLzsRErxON8aBSR6WpUKJdg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.Binder": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + "resolved": "10.0.5", + "contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g==" }, "Microsoft.Extensions.Resilience": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", + "resolved": "10.4.0", + "contentHash": "41CCbJJPsDWU6NsmKfANHkfT/+KCBlZZqQ1eBoQhhW0xqGCiWmUlMdi2BoaM/GcwKHX5WiQL/IESROmgk0Owfw==", "dependencies": { - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3", - "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", + "Microsoft.Extensions.Diagnostics": "10.0.4", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.4.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.4", + "Microsoft.Extensions.Telemetry.Abstractions": "10.4.0", "Polly.Extensions": "8.4.2", "Polly.RateLimiting": "8.4.2" } }, "Microsoft.Extensions.ServiceDiscovery.Abstractions": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==", + "resolved": "10.4.0", + "contentHash": "HkBb7cdi27tkQiQw1anQFbXe+A3pjRwDKgVbd/DD9fMAO2X9abK0FEyM/tNVXjW3lwOWl2tF+Xij/DqI6i+JTg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Features": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.4", + "Microsoft.Extensions.Configuration.Binder": "10.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.4", + "Microsoft.Extensions.Features": "10.0.4", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4", + "Microsoft.Extensions.Primitives": "10.0.4" } }, "Microsoft.Extensions.Telemetry": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", + "resolved": "10.4.0", + "contentHash": "AbHleTzdpGPjA6RpOjKVHEYx7SoBRnJ2bwAbbPa3aGB7HiVwBmeTJhBGhtIBiuIW0VpKDS8x+bV5iWqpBRIf4w==", "dependencies": { - "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", - "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", - "Microsoft.Extensions.Logging.Configuration": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3", - "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" + "Microsoft.Extensions.AmbientMetadata.Application": "10.4.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.4.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.4", + "Microsoft.Extensions.ObjectPool": "10.0.4", + "Microsoft.Extensions.Telemetry.Abstractions": "10.4.0" } }, "Microsoft.Extensions.Telemetry.Abstractions": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", + "resolved": "10.4.0", + "contentHash": "3b2uVa4voJfLLg39BPCKQS0ZgnpEZFkKf7YmnMVlM5FQJYBPOuePIQdnEK1/Oxd+w3GscxGYuE7IMOXDwixZtQ==", "dependencies": { - "Microsoft.Extensions.Compliance.Abstractions": "10.3.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.Extensions.Compliance.Abstractions": "10.4.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.ObjectPool": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4" } }, "Microsoft.IdentityModel.Abstractions": { @@ -749,6 +760,15 @@ "resolved": "7.6.0-rc.1", "contentHash": "HFG8NIyKCrZ7SM/lgewFpEQ13kbsRQm6c8ihdo2v+Am0XfftCw+cxzKOpNXlti8ZBEkPksZzkbxlPeAds0PZGA==" }, + "MimeKit": { + "type": "Transitive", + "resolved": "4.15.1", + "contentHash": "cxCcQhD0zhboFoG136jJuJtQjNRDJ+BxBm3f2vWn+53bff/CRo+K1mAkWjsW4Wuyy5O22F40MdMG2nRzQu1cJw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "System.Security.Cryptography.Pkcs": "10.0.0" + } + }, "MSTest.Analyzers": { "type": "Transitive", "resolved": "4.1.0", @@ -779,8 +799,8 @@ }, "Npgsql": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "resolved": "10.0.2", + "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==", "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "10.0.0" } @@ -1285,6 +1305,7 @@ "type": "Project", "dependencies": { "Grpc.AspNetCore": "[2.76.0, )", + "MailKit": "[4.15.1, )", "Microsoft.PowerShell.SDK": "[7.6.0-rc.1, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.Console": "[6.1.1, )", @@ -1300,8 +1321,8 @@ "type": "Project", "dependencies": { "Google.Protobuf": "[3.34.0, )", - "Microsoft.AspNetCore.Authorization": "[10.0.3, )", - "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.AspNetCore.Authorization": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.5, )", "Microsoft.IdentityModel.Tokens": "[8.16.0, )", "Werkr.Common.Configuration": "[1.0.0, )" } @@ -1313,8 +1334,8 @@ "type": "Project", "dependencies": { "Grpc.Net.Client": "[2.76.0, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.3, )", - "System.Security.Cryptography.ProtectedData": "[10.0.3, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "System.Security.Cryptography.ProtectedData": "[10.0.5, )", "Werkr.Common": "[1.0.0, )", "Werkr.Data": "[1.0.0, )" } @@ -1323,17 +1344,17 @@ "type": "Project", "dependencies": { "EFCore.NamingConventions": "[10.0.1, )", - "Microsoft.EntityFrameworkCore": "[10.0.3, )", - "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", - "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.5, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.5, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )", "Werkr.Common": "[1.0.0, )" } }, "werkr.servicedefaults": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", - "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", + "Microsoft.Extensions.Http.Resilience": "[10.4.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.4.0, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" } @@ -1386,116 +1407,131 @@ "Microsoft.Extensions.Http": "8.0.0" } }, + "Grpc.Tools": { + "type": "CentralTransitive", + "requested": "[2.78.0, )", + "resolved": "2.78.0", + "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" + }, + "MailKit": { + "type": "CentralTransitive", + "requested": "[4.15.1, )", + "resolved": "4.15.1", + "contentHash": "4mLbqTbH3ctd0NlukHjVQbU3ZnNDuCtB6ttNZDLPZLWMA2Dr31rh/eCSTqOwDojUX8zfDOVaxstMgJTE9PwZNA==", + "dependencies": { + "MimeKit": "4.15.1" + } + }, "Microsoft.AspNetCore.Authorization": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "NbFi4wN6fUvZK4AKmixpfx0IvqtVimKEn8ZX28LkzZBVo09YnLbyRrJ1001IVQDLbV+aYpS/cLhVJu5JD0rY5A==", "dependencies": { - "Microsoft.AspNetCore.Metadata": "10.0.3", - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.AspNetCore.Metadata": "10.0.5", + "Microsoft.Extensions.Diagnostics": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" } }, "Microsoft.Data.Sqlite.Core": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==", "dependencies": { "SQLitePCLRaw.core": "2.1.11" } }, "Microsoft.EntityFrameworkCore": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9tNBmK3EpYVGRQLiqP+bqK2m+TD0Gv//4vCzR7ZOgl4FWzCFyOpYdIVka13M4kcBdPdSJcs3wbHr3rmzOqbIMA==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.5", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" } }, "Microsoft.EntityFrameworkCore.Sqlite": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "lxeRviglTkkmzYJVJ600yb6gJjnf5za9v7uH+0byuSXTGv7U8cT6hz7qRTmiGSOfLcl86QFdy2BBKaUFd6NQug==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyModel": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", "SQLitePCLRaw.core": "2.1.11" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.Configuration.Json": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "brBM/WP0YAUYh2+QqSYVdK8eQHYQTtTEUJXJ+84Zkdo2buGLja9VSrMIhgoeBUU7JBmcskAib8Lb/N83bvxgYQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5" } }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5" } }, "Microsoft.Extensions.Http.Resilience": { "type": "CentralTransitive", - "requested": "[10.3.0, )", - "resolved": "10.3.0", - "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", + "requested": "[10.4.0, )", + "resolved": "10.4.0", + "contentHash": "HbkUsPUC7vLy2TaDbdA9aooW64n9yX4sUppRuiJ1cOzzU1FUW+MVEotm6kYVq6AuUI9xwFSBhRFzA03blmk3VA==", "dependencies": { - "Microsoft.Extensions.Http.Diagnostics": "10.3.0", - "Microsoft.Extensions.ObjectPool": "10.0.3", - "Microsoft.Extensions.Resilience": "10.3.0" + "Microsoft.Extensions.Http.Diagnostics": "10.4.0", + "Microsoft.Extensions.ObjectPool": "10.0.4", + "Microsoft.Extensions.Resilience": "10.4.0" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" } }, "Microsoft.Extensions.ServiceDiscovery": { "type": "CentralTransitive", - "requested": "[10.3.0, )", - "resolved": "10.3.0", - "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", + "requested": "[10.4.0, )", + "resolved": "10.4.0", + "contentHash": "RznZAH6L4RNvroECT5JpqfFQJjHTn+8N7+ThSgYutbshkuymFeL/uBIZt1CM8LOdpPPhn4//a5fLUah9/k7ayQ==", "dependencies": { - "Microsoft.Extensions.Http": "10.0.3", - "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" + "Microsoft.Extensions.Http": "10.0.4", + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.4.0" } }, "Microsoft.IdentityModel.Tokens": { @@ -1510,13 +1546,13 @@ }, "Npgsql.EntityFrameworkCore.PostgreSQL": { "type": "CentralTransitive", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "P6EwH0Q4xkaA264iNZDqCPhWt8pscfUGxXazDQg4noBfqjoOlk4hKWfvBjF9ZX3R/9JybRmmJfmxr2iBMj0EpA==", "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", - "Npgsql": "10.0.0" + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.2" } }, "OpenTelemetry.Exporter.OpenTelemetryProtocol": { @@ -1584,9 +1620,9 @@ }, "System.Security.Cryptography.ProtectedData": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "kxR4O/8o32eNN3m4qbLe3UifYqeyEpallCyVAsLvL5ZFJVyT3JCb+9du/WHfC09VyJh1Q+p/Gd4+AwM7Rz4acg==" } } } diff --git a/src/Test/Werkr.Tests.Data/AssemblyAttributes.cs b/src/Test/Werkr.Tests.Data/AssemblyAttributes.cs new file mode 100644 index 0000000..99be6a5 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/AssemblyAttributes.cs @@ -0,0 +1 @@ +[assembly: Parallelize( Workers = 0, Scope = ExecutionScope.ClassLevel )] diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/AgentConnectionManagerTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/AgentConnectionManagerTests.cs index 8a87b42..175bafc 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Communication/AgentConnectionManagerTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/AgentConnectionManagerTests.cs @@ -220,7 +220,7 @@ public void CreateCallOptions_SetsMetadataCorrectly( ) { CallOptions options = AgentConnectionManager.CreateCallOptions( conn, callId, - TestContext.CancellationToken + cancellationToken: TestContext.CancellationToken ); Assert.IsNotNull( options.Headers ); diff --git a/src/Test/Werkr.Tests.Data/Unit/Communication/WorkflowEventBroadcasterTests.cs b/src/Test/Werkr.Tests.Data/Unit/Communication/WorkflowEventBroadcasterTests.cs new file mode 100644 index 0000000..ba42093 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Communication/WorkflowEventBroadcasterTests.cs @@ -0,0 +1,153 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Core.Communication; + +namespace Werkr.Tests.Data.Unit.Communication; + +/// +/// Unit tests for the class, validating subscription management, event +/// fan-out delivery, and thread-safe subscriber count tracking. +/// +[TestClass] +public class WorkflowEventBroadcasterTests { + /// + /// The instance under test. + /// + private WorkflowEventBroadcaster _broadcaster = null!; + + /// + /// Gets or sets the MSTest providing per-test cancellation tokens and metadata. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Creates a fresh for each test. + /// + [TestInitialize] + public void TestInit( ) { + _broadcaster = new WorkflowEventBroadcaster( + NullLogger.Instance + ); + } + + /// + /// Verifies that returns a non-null subscription + /// with a readable channel. + /// + [TestMethod] + public void Subscribe_ReturnsSubscriptionWithReader( ) { + using WorkflowEventSubscription sub = _broadcaster.Subscribe( ); + + Assert.IsNotNull( sub ); + Assert.IsNotNull( sub.Reader ); + } + + /// + /// Verifies that published events are delivered to a single subscriber. + /// + [TestMethod] + public async Task Publish_DeliversEventToSubscriber( ) { + CancellationToken ct = TestContext.CancellationToken; + using WorkflowEventSubscription sub = _broadcaster.Subscribe( ); + + Guid runId = Guid.NewGuid( ); + StepStartedEvent evt = new( runId, 1, "Step1", 10, DateTime.UtcNow ); + _broadcaster.Publish( evt ); + + bool available = await sub.Reader.WaitToReadAsync( ct ); + + Assert.IsTrue( available ); + Assert.IsTrue( sub.Reader.TryRead( out WorkflowEvent? received ) ); + Assert.AreEqual( runId, received!.WorkflowRunId ); + } + + /// + /// Verifies that published events fan out to all active subscribers. + /// + [TestMethod] + public async Task Publish_FansOutToMultipleSubscribers( ) { + CancellationToken ct = TestContext.CancellationToken; + using WorkflowEventSubscription sub1 = _broadcaster.Subscribe( ); + using WorkflowEventSubscription sub2 = _broadcaster.Subscribe( ); + + Guid runId = Guid.NewGuid( ); + StepStartedEvent evt = new( runId, 1, "Step1", 10, DateTime.UtcNow ); + _broadcaster.Publish( evt ); + + Assert.IsTrue( await sub1.Reader.WaitToReadAsync( ct ) ); + Assert.IsTrue( sub1.Reader.TryRead( out WorkflowEvent? received1 ) ); + Assert.AreEqual( runId, received1!.WorkflowRunId ); + + Assert.IsTrue( await sub2.Reader.WaitToReadAsync( ct ) ); + Assert.IsTrue( sub2.Reader.TryRead( out WorkflowEvent? received2 ) ); + Assert.AreEqual( runId, received2!.WorkflowRunId ); + } + + /// + /// Verifies that disposing a subscription removes it from the broadcaster so + /// subsequent publishes are not delivered to the disposed subscriber. + /// + [TestMethod] + public void Dispose_RemovesSubscriber( ) { + WorkflowEventSubscription sub = _broadcaster.Subscribe( ); + sub.Dispose( ); + + Guid runId = Guid.NewGuid( ); + StepStartedEvent evt = new( runId, 1, "Step1", 10, DateTime.UtcNow ); + _broadcaster.Publish( evt ); + + // Channel was completed on unsubscribe; TryRead should return false. + Assert.IsFalse( sub.Reader.TryRead( out _ ) ); + } + + /// + /// Verifies that concurrent subscribe and unsubscribe operations do not corrupt + /// the subscriber list. + /// + [TestMethod] + public async Task ConcurrentSubscribeUnsubscribe_DoesNotCorruptState( ) { + CancellationToken ct = TestContext.CancellationToken; + const int Iterations = 100; + List tasks = []; + + for (int i = 0; i < Iterations; i++) { + tasks.Add( Task.Run( ( ) => { + WorkflowEventSubscription sub = _broadcaster.Subscribe( ); + sub.Dispose( ); + }, ct ) ); + } + + await Task.WhenAll( tasks ); + + // After all subscribe/unsubscribe pairs complete, a new publish should succeed + // without throwing (no corrupted list). + using WorkflowEventSubscription final = _broadcaster.Subscribe( ); + Guid runId = Guid.NewGuid( ); + StepStartedEvent evt = new( runId, 1, "Step1", 10, DateTime.UtcNow ); + _broadcaster.Publish( evt ); + + Assert.IsTrue( await final.Reader.WaitToReadAsync( ct ) ); + Assert.IsTrue( final.Reader.TryRead( out WorkflowEvent? received ) ); + Assert.AreEqual( runId, received!.WorkflowRunId ); + } + + /// + /// Verifies that publishing with no subscribers does not throw. + /// + [TestMethod] + public void Publish_WithNoSubscribers_DoesNotThrow( ) { + Guid runId = Guid.NewGuid( ); + StepStartedEvent evt = new( runId, 1, "Step1", 10, DateTime.UtcNow ); + + _broadcaster.Publish( evt ); + } + + /// + /// Verifies that disposing a subscription twice does not throw. + /// + [TestMethod] + public void Dispose_CalledTwice_DoesNotThrow( ) { + WorkflowEventSubscription sub = _broadcaster.Subscribe( ); + sub.Dispose( ); + sub.Dispose( ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Configuration/ConfigurationExtensionsTests.cs b/src/Test/Werkr.Tests.Data/Unit/Configuration/ConfigurationExtensionsTests.cs index 2d06b6b..04a0091 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Configuration/ConfigurationExtensionsTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Configuration/ConfigurationExtensionsTests.cs @@ -27,7 +27,7 @@ public void AddWerkrConfigPath_WithValidJsonFile_LoadsValues( ) { tempFile ); - IConfigurationBuilder builder = new ConfigurationBuilder(); + ConfigurationBuilder builder = new(); // Act _ = builder.AddWerkrConfigPath( ); @@ -58,7 +58,7 @@ public void AddWerkrConfigPath_WithNoEnvVar_ReturnsEmptyConfig( ) { "WERKR_CONFIG_PATH", null ); - IConfigurationBuilder builder = new ConfigurationBuilder(); + ConfigurationBuilder builder = new(); // Act _ = builder.AddWerkrConfigPath( ); @@ -85,7 +85,7 @@ public void AddWerkrConfigPath_WithMissingFile_DoesNotThrow( ) { "WERKR_CONFIG_PATH", missingPath ); - IConfigurationBuilder builder = new ConfigurationBuilder(); + ConfigurationBuilder builder = new(); try { // Act diff --git a/src/Test/Werkr.Tests.Data/Unit/Cryptography/HybridEncryptionTests.cs b/src/Test/Werkr.Tests.Data/Unit/Cryptography/HybridEncryptionTests.cs index 59b5706..65bf0cd 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Cryptography/HybridEncryptionTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Cryptography/HybridEncryptionTests.cs @@ -19,7 +19,7 @@ public class HybridEncryptionTests { /// Generates the RSA key pair once for all tests in this class. /// [ClassInitialize] - public static void ClassInit( TestContext context ) { + public static void ClassInit( TestContext _ ) { // Generate once — RSA-4096 is required for hybrid operations (HybridDecrypt hardcodes 512-byte RSA block). s_keyPair = EncryptionProvider.GenerateRSAKeyPair( ); } diff --git a/src/Test/Werkr.Tests.Data/Unit/Endpoints/FilterEndpointTests.cs b/src/Test/Werkr.Tests.Data/Unit/Endpoints/FilterEndpointTests.cs new file mode 100644 index 0000000..e7ffc0c --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Endpoints/FilterEndpointTests.cs @@ -0,0 +1,254 @@ +using System.Reflection; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Werkr.Data; +using Werkr.Data.Entities.Settings; + +namespace Werkr.Tests.Data.Unit.Endpoints; + +/// +/// Unit tests for the FilterEndpoints class, validating page key allowlist completeness +/// and that filter CRUD operations enforce ownership and produce correct persistence results. +/// +[TestClass] +public class FilterEndpointTests { + /// + /// The in-memory SQLite connection used for database operations. + /// + private SqliteConnection _connection = null!; + /// + /// The SQLite-backed used for test data persistence. + /// + private SqliteWerkrDbContext _dbContext = null!; + + /// + /// Gets or sets the MSTest providing per-test cancellation tokens and metadata. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// The complete set of valid page keys that the filter endpoints must accept. + /// + private static readonly HashSet s_expectedPageKeys = [ + "runs", "workflows", "jobs", "agents", "schedules", "tasks", + "all-workflow-runs", "workflow-dashboard" + ]; + + /// + /// Creates an in-memory SQLite database and the schema for each test. + /// + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + } + + /// + /// Disposes the database context and SQLite connection after each test. + /// + [TestCleanup] + public void TestCleanup( ) { + _dbContext?.Dispose( ); + _connection?.Dispose( ); + } + + /// + /// Verifies that the s_validPageKeys field on FilterEndpoints contains exactly the + /// expected set of page keys including all-workflow-runs and workflow-dashboard. + /// + [TestMethod] + public void ValidPageKeys_ContainsAllExpectedKeys( ) { + Assembly apiAssembly = Assembly.Load( "Werkr.Api" ); + Type? endpointsType = apiAssembly.GetType( "Werkr.Api.Endpoints.FilterEndpoints" ); + Assert.IsNotNull( endpointsType, "FilterEndpoints type not found in Werkr.Api assembly" ); + + FieldInfo? field = endpointsType.GetField( + "s_validPageKeys", + BindingFlags.NonPublic | BindingFlags.Static + ); + + Assert.IsNotNull( field, "s_validPageKeys field not found on FilterEndpoints" ); + + object? value = field.GetValue( null ); + _ = Assert.IsInstanceOfType>( value ); + + HashSet actualKeys = (HashSet)value; + + foreach (string expected in s_expectedPageKeys) { + Assert.Contains( + expected, actualKeys, + $"Missing page key: '{expected}'" + ); + } + + Assert.HasCount( + s_expectedPageKeys.Count, + actualKeys, + $"Page key count mismatch. Expected: {s_expectedPageKeys.Count}, Actual: {actualKeys.Count}" + ); + } + + /// + /// Verifies that creating a filter persists the entity with the correct owner and page key. + /// + [TestMethod] + public async Task CreateFilter_PersistsWithCorrectOwnerAndPageKey( ) { + CancellationToken ct = TestContext.CancellationToken; + string userId = "user-1"; + + SavedFilter entity = new( ) { + OwnerId = userId, + PageKey = "runs", + Name = "My Filter", + CriteriaJson = "{\"status\":\"Running\"}", + IsShared = false, + Created = DateTime.UtcNow, + LastUpdated = DateTime.UtcNow, + Version = 1, + }; + + _ = _dbContext.SavedFilters.Add( entity ); + _ = await _dbContext.SaveChangesAsync( ct ); + + SavedFilter? loaded = await _dbContext.SavedFilters + .FirstOrDefaultAsync( f => f.OwnerId == userId && f.PageKey == "runs", ct ); + + Assert.IsNotNull( loaded ); + Assert.AreEqual( "My Filter", loaded.Name ); + Assert.AreEqual( "{\"status\":\"Running\"}", loaded.CriteriaJson ); + Assert.IsFalse( loaded.IsShared ); + } + + /// + /// Verifies that filters can be queried by page key and include both owned and shared filters. + /// + [TestMethod] + public async Task QueryFilters_ReturnsBothOwnedAndShared( ) { + CancellationToken ct = TestContext.CancellationToken; + string userId = "user-1"; + + SavedFilter owned = new( ) { + OwnerId = userId, + PageKey = "runs", + Name = "My Filter", + CriteriaJson = "{}", + Created = DateTime.UtcNow, + LastUpdated = DateTime.UtcNow, + Version = 1, + }; + + SavedFilter shared = new( ) { + OwnerId = "other-user", + PageKey = "runs", + Name = "Shared Filter", + CriteriaJson = "{}", + IsShared = true, + Created = DateTime.UtcNow, + LastUpdated = DateTime.UtcNow, + Version = 1, + }; + + SavedFilter differentPage = new( ) { + OwnerId = userId, + PageKey = "jobs", + Name = "Jobs Filter", + CriteriaJson = "{}", + Created = DateTime.UtcNow, + LastUpdated = DateTime.UtcNow, + Version = 1, + }; + + _dbContext.SavedFilters.AddRange( owned, shared, differentPage ); + _ = await _dbContext.SaveChangesAsync( ct ); + + List results = await _dbContext.SavedFilters + .Where( f => f.PageKey == "runs" && (f.OwnerId == userId || f.IsShared) ) + .OrderBy( f => f.Name ) + .ToListAsync( ct ); + + Assert.HasCount( 2, results ); + Assert.AreEqual( "My Filter", results[0].Name ); + Assert.AreEqual( "Shared Filter", results[1].Name ); + } + + /// + /// Verifies that deleting a filter only removes the targeted entity. + /// + [TestMethod] + public async Task DeleteFilter_RemovesOnlyTargetEntity( ) { + CancellationToken ct = TestContext.CancellationToken; + + SavedFilter filter1 = new( ) { + OwnerId = "user-1", + PageKey = "runs", + Name = "Filter 1", + CriteriaJson = "{}", + Created = DateTime.UtcNow, + LastUpdated = DateTime.UtcNow, + Version = 1, + }; + + SavedFilter filter2 = new( ) { + OwnerId = "user-1", + PageKey = "runs", + Name = "Filter 2", + CriteriaJson = "{}", + Created = DateTime.UtcNow, + LastUpdated = DateTime.UtcNow, + Version = 1, + }; + + _dbContext.SavedFilters.AddRange( filter1, filter2 ); + _ = await _dbContext.SaveChangesAsync( ct ); + + _ = _dbContext.SavedFilters.Remove( filter1 ); + _ = await _dbContext.SaveChangesAsync( ct ); + + List remaining = await _dbContext.SavedFilters.ToListAsync( ct ); + + Assert.HasCount( 1, remaining ); + Assert.AreEqual( "Filter 2", remaining[0].Name ); + } + + /// + /// Verifies that updating a filter increments the version and persists the new values. + /// + [TestMethod] + public async Task UpdateFilter_IncrementsVersionAndPersists( ) { + CancellationToken ct = TestContext.CancellationToken; + + SavedFilter entity = new( ) { + OwnerId = "user-1", + PageKey = "runs", + Name = "Original", + CriteriaJson = "{}", + Created = DateTime.UtcNow, + LastUpdated = DateTime.UtcNow, + Version = 1, + }; + + _ = _dbContext.SavedFilters.Add( entity ); + _ = await _dbContext.SaveChangesAsync( ct ); + + entity.Name = "Updated"; + entity.CriteriaJson = "{\"status\":\"Failed\"}"; + entity.Version++; + entity.LastUpdated = DateTime.UtcNow; + _ = await _dbContext.SaveChangesAsync( ct ); + + SavedFilter? loaded = await _dbContext.SavedFilters + .AsNoTracking( ) + .FirstOrDefaultAsync( f => f.Id == entity.Id, ct ); + + Assert.IsNotNull( loaded ); + Assert.AreEqual( "Updated", loaded.Name ); + Assert.AreEqual( "{\"status\":\"Failed\"}", loaded.CriteriaJson ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs index 3f514fc..ed525ec 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Registration/BundleExpirationServiceTests.cs @@ -33,14 +33,21 @@ public class BundleExpirationServiceTests { /// /// Creates an in-memory SQLite database and registers services. + /// Uses a named shared-cache database so each scope gets its own connection, + /// preventing "unable to delete/modify user-function due to active statements" + /// when the background service and polling loop access the database concurrently. /// [TestInitialize] public void TestInit( ) { - _connection = new SqliteConnection( "DataSource=:memory:" ); + string dbName = $"bundle_expiration_{Guid.NewGuid():N}"; + string connectionString = $"DataSource=file:{dbName}?mode=memory&cache=shared"; + + // Keep-alive connection preserves the shared in-memory database + _connection = new SqliteConnection( connectionString ); _connection.Open( ); ServiceCollection services = new( ); - _ = services.AddDbContext( opt => opt.UseSqlite( _connection ) ); + _ = services.AddDbContext( opt => opt.UseSqlite( connectionString ) ); _ = services.AddScoped( sp => sp.GetRequiredService( ) ); _serviceProvider = services.BuildServiceProvider( ); diff --git a/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationServiceTests.cs index 2f6935d..ac40fb9 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Registration/RegistrationServiceTests.cs @@ -51,7 +51,7 @@ public class RegistrationServiceTests { /// Generates server and agent RSA key pairs once for all tests. /// [ClassInitialize] - public static void ClassInit( TestContext context ) { + public static void ClassInit( TestContext _ ) { // Pre-generate RSA-4096 keys to avoid per-test overhead. s_serverKeys = EncryptionProvider.GenerateRSAKeyPair( ); s_agentKeys = EncryptionProvider.GenerateRSAKeyPair( ); diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayCalendarServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayCalendarServiceTests.cs index b53d522..bf24678 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayCalendarServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayCalendarServiceTests.cs @@ -2,7 +2,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; - using Werkr.Api.Services; using Werkr.Core.Communication; using Werkr.Core.Scheduling; diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayDateServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayDateServiceTests.cs index a1be197..acd19d0 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayDateServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/HolidayDateServiceTests.cs @@ -1,7 +1,6 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; - using Werkr.Core.Scheduling; using Werkr.Data; using Werkr.Data.Calendar.Enums; @@ -92,7 +91,7 @@ private async Task SeedEmptyCalendarAsync( CancellationToken ct [TestMethod] public async Task MaterializeDates_CreatesDatesFromRules( ) { - CancellationToken ct = TestContext.CancellationTokenSource.Token; + CancellationToken ct = TestContext.CancellationToken; HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); await _service.MaterializeDatesAsync( cal.Id, 2026, 2026, ct ); @@ -107,7 +106,7 @@ public async Task MaterializeDates_CreatesDatesFromRules( ) { [TestMethod] public async Task MaterializeDates_MultiYear_CreatesAll( ) { - CancellationToken ct = TestContext.CancellationTokenSource.Token; + CancellationToken ct = TestContext.CancellationToken; HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); await _service.MaterializeDatesAsync( cal.Id, 2025, 2027, ct ); @@ -121,7 +120,7 @@ public async Task MaterializeDates_MultiYear_CreatesAll( ) { [TestMethod] public async Task MaterializeDates_Idempotent_NoDoubleInsert( ) { - CancellationToken ct = TestContext.CancellationTokenSource.Token; + CancellationToken ct = TestContext.CancellationToken; HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); await _service.MaterializeDatesAsync( cal.Id, 2026, 2026, ct ); @@ -135,7 +134,7 @@ public async Task MaterializeDates_Idempotent_NoDoubleInsert( ) { [TestMethod] public async Task MaterializeDates_EmptyCalendar_NoExceptions( ) { - CancellationToken ct = TestContext.CancellationTokenSource.Token; + CancellationToken ct = TestContext.CancellationToken; HolidayCalendar cal = await SeedEmptyCalendarAsync( ct ); await _service.MaterializeDatesAsync( cal.Id, 2026, 2026, ct ); @@ -148,7 +147,7 @@ public async Task MaterializeDates_EmptyCalendar_NoExceptions( ) { [TestMethod] public async Task MaterializeDates_NonexistentCalendar_NoExceptions( ) { - CancellationToken ct = TestContext.CancellationTokenSource.Token; + CancellationToken ct = TestContext.CancellationToken; await _service.MaterializeDatesAsync( Guid.NewGuid( ), 2026, 2026, ct ); // Should not throw } @@ -157,7 +156,7 @@ public async Task MaterializeDates_NonexistentCalendar_NoExceptions( ) { [TestMethod] public async Task InvalidateCache_RemovesRuleGenerated_PreservesManual( ) { - CancellationToken ct = TestContext.CancellationTokenSource.Token; + CancellationToken ct = TestContext.CancellationToken; HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); // Materialize rule-generated dates @@ -191,7 +190,7 @@ public async Task InvalidateCache_RemovesRuleGenerated_PreservesManual( ) { [TestMethod] public async Task GetDatesForRange_AutoMaterializesWhenMissing( ) { - CancellationToken ct = TestContext.CancellationTokenSource.Token; + CancellationToken ct = TestContext.CancellationToken; HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); // No dates materialized yet @@ -203,7 +202,7 @@ public async Task GetDatesForRange_AutoMaterializesWhenMissing( ) { [TestMethod] public async Task GetDatesForRange_IncludesManualDates( ) { - CancellationToken ct = TestContext.CancellationTokenSource.Token; + CancellationToken ct = TestContext.CancellationToken; HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); // Add a manual date @@ -227,7 +226,7 @@ public async Task GetDatesForRange_IncludesManualDates( ) { [TestMethod] public async Task MaterializeDates_MergesOntoManualEntry( ) { - CancellationToken ct = TestContext.CancellationTokenSource.Token; + CancellationToken ct = TestContext.CancellationToken; HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); // Pre-insert a manual entry matching a rule's output date @@ -258,7 +257,7 @@ public async Task MaterializeDates_MergesOntoManualEntry( ) { [TestMethod] public async Task EnsureMaterialized_OnlyRunsOnce( ) { - CancellationToken ct = TestContext.CancellationTokenSource.Token; + CancellationToken ct = TestContext.CancellationToken; HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); await _service.EnsureMaterializedAsync( cal.Id, 2026, ct ); @@ -274,7 +273,7 @@ public async Task EnsureMaterialized_OnlyRunsOnce( ) { [TestMethod] public async Task GetDatesForRange_EmptyDateRange_ReturnsEmpty( ) { - CancellationToken ct = TestContext.CancellationTokenSource.Token; + CancellationToken ct = TestContext.CancellationToken; HolidayCalendar cal = await SeedCalendarWithRulesAsync( ct ); await _service.MaterializeDatesAsync( cal.Id, 2026, 2026, ct ); diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/RetryFromFailedServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/RetryFromFailedServiceTests.cs new file mode 100644 index 0000000..c0817f7 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/RetryFromFailedServiceTests.cs @@ -0,0 +1,516 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Werkr.Common.Models; +using Werkr.Core.Scheduling; +using Werkr.Data; +using Werkr.Data.Entities.Tasks; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Tests.Data.Unit.Scheduling; + +/// +/// Unit tests for , validating retry-from-failed orchestration +/// including variable override batching, failed-step validation, and schedule creation. +/// +[TestClass] +public class RetryFromFailedServiceTests { + /// + /// The in-memory SQLite connection used for database operations. + /// + private SqliteConnection _connection = null!; + /// + /// The SQLite-backed used for test data persistence. + /// + private SqliteWerkrDbContext _dbContext = null!; + /// + /// The instance under test. + /// + private RetryFromFailedService _service = null!; + + /// + /// Gets or sets the MSTest providing per-test cancellation tokens and metadata. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Creates an in-memory SQLite database, the schema, and the service under test. + /// + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + + _service = new RetryFromFailedService( + _dbContext, + NullLogger.Instance + ); + } + + /// + /// Disposes the database context and SQLite connection after each test. + /// + [TestCleanup] + public void TestCleanup( ) { + _dbContext?.Dispose( ); + _connection?.Dispose( ); + } + + #region Helpers + + /// + /// Seeds a minimal workflow with a single task, step, run, and failed step execution. + /// Returns a tuple of (workflowId, runId, stepId). + /// + private async Task<(long WorkflowId, Guid RunId, long StepId)> SeedFailedRunAsync( + CancellationToken ct ) { + + Workflow workflow = new( ) { Name = "Test Workflow", Description = "Test" }; + _ = _dbContext.Set( ).Add( workflow ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WerkrTask task = new( ) { + Name = "Test Task", + WorkflowId = workflow.Id, + ActionType = TaskActionType.PowerShellCommand, + Content = "Write-Output 'test'", + TargetTags = ["default"], + }; + _ = _dbContext.Set( ).Add( task ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowStep step = new( ) { + WorkflowId = workflow.Id, + TaskId = task.Id, + Order = 1, + }; + _ = _dbContext.WorkflowSteps.Add( step ); + _ = await _dbContext.SaveChangesAsync( ct ); + + Guid runId = Guid.NewGuid( ); + WorkflowRun run = new( ) { + Id = runId, + WorkflowId = workflow.Id, + StartTime = DateTime.UtcNow.AddMinutes( -5 ), + EndTime = DateTime.UtcNow.AddMinutes( -1 ), + Status = WorkflowRunStatus.Failed, + }; + _ = _dbContext.WorkflowRuns.Add( run ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowStepExecution failedExecution = new( ) { + WorkflowRunId = runId, + StepId = step.Id, + Attempt = 1, + Status = StepExecutionStatus.Failed, + }; + _ = _dbContext.WorkflowStepExecutions.Add( failedExecution ); + _ = await _dbContext.SaveChangesAsync( ct ); + + return (workflow.Id, runId, step.Id); + } + + #endregion + + /// + /// Verifies that retry succeeds for a valid failed run and creates a new schedule. + /// + [TestMethod] + public async Task RetryAsync_ValidFailedRun_ReturnsResult( ) { + CancellationToken ct = TestContext.CancellationToken; + (long workflowId, Guid runId, long stepId) = await SeedFailedRunAsync( ct ); + + RetryFromFailedService.RetryResult result = await _service.RetryAsync( + workflowId, runId, stepId, null, ct + ); + + Assert.AreEqual( runId, result.RunId ); + Assert.AreEqual( stepId, result.RetryFromStepId ); + Assert.AreEqual( 1, result.ResetStepCount ); + } + + /// + /// Verifies that retry transitions the run from Failed to Running. + /// + [TestMethod] + public async Task RetryAsync_TransitionsRunToRunning( ) { + CancellationToken ct = TestContext.CancellationToken; + (long workflowId, Guid runId, long stepId) = await SeedFailedRunAsync( ct ); + + _ = await _service.RetryAsync( workflowId, runId, stepId, null, ct ); + + WorkflowRun run = await _dbContext.WorkflowRuns.AsNoTracking( ).FirstAsync( r => r.Id == runId, ct ); + + Assert.AreEqual( WorkflowRunStatus.Running, run.Status ); + Assert.IsNull( run.EndTime ); + } + + /// + /// Verifies that retry creates a new Pending step execution with an incremented attempt number. + /// + [TestMethod] + public async Task RetryAsync_CreatesNewPendingExecution( ) { + CancellationToken ct = TestContext.CancellationToken; + (long workflowId, Guid runId, long stepId) = await SeedFailedRunAsync( ct ); + + _ = await _service.RetryAsync( workflowId, runId, stepId, null, ct ); + + List executions = await _dbContext.WorkflowStepExecutions + .Where( e => e.WorkflowRunId == runId && e.StepId == stepId ) + .OrderBy( e => e.Attempt ) + .ToListAsync( ct ); + + Assert.HasCount( 2, executions ); + Assert.AreEqual( StepExecutionStatus.Failed, executions[0].Status ); + Assert.AreEqual( 1, executions[0].Attempt ); + Assert.AreEqual( StepExecutionStatus.Pending, executions[1].Status ); + Assert.AreEqual( 2, executions[1].Attempt ); + } + + /// + /// Verifies that variable overrides are persisted with incremented versions using + /// the batch query (not N+1). + /// + [TestMethod] + public async Task RetryAsync_WithVariableOverrides_PersistsNewVersions( ) { + CancellationToken ct = TestContext.CancellationToken; + (long workflowId, Guid runId, long stepId) = await SeedFailedRunAsync( ct ); + + // Seed existing variables (version 1) + WorkflowRunVariable existingVar1 = new( ) { + WorkflowRunId = runId, + VariableName = "Env", + Value = "staging", + Version = 1, + Source = VariableSource.Default, + Created = DateTime.UtcNow, + }; + WorkflowRunVariable existingVar2 = new( ) { + WorkflowRunId = runId, + VariableName = "Retries", + Value = "3", + Version = 1, + Source = VariableSource.Default, + Created = DateTime.UtcNow, + }; + _dbContext.Set( ).AddRange( existingVar1, existingVar2 ); + _ = await _dbContext.SaveChangesAsync( ct ); + + Dictionary overrides = new( ) { + ["Env"] = "production", + ["Retries"] = "5", + }; + + _ = await _service.RetryAsync( workflowId, runId, stepId, overrides, ct ); + + List envVars = await _dbContext.Set( ) + .Where( v => v.WorkflowRunId == runId && v.VariableName == "Env" ) + .OrderBy( v => v.Version ) + .ToListAsync( ct ); + + Assert.HasCount( 2, envVars ); + Assert.AreEqual( 1, envVars[0].Version ); + Assert.AreEqual( "staging", envVars[0].Value ); + Assert.AreEqual( 2, envVars[1].Version ); + Assert.AreEqual( "production", envVars[1].Value ); + Assert.AreEqual( VariableSource.ReExecutionEdit, envVars[1].Source ); + + List retriesVars = await _dbContext.Set( ) + .Where( v => v.WorkflowRunId == runId && v.VariableName == "Retries" ) + .OrderBy( v => v.Version ) + .ToListAsync( ct ); + + Assert.HasCount( 2, retriesVars ); + Assert.AreEqual( 2, retriesVars[1].Version ); + Assert.AreEqual( "5", retriesVars[1].Value ); + } + + /// + /// Verifies that overrides for new variables (no prior version) start at version 1. + /// + [TestMethod] + public async Task RetryAsync_WithNewVariable_StartsAtVersionOne( ) { + CancellationToken ct = TestContext.CancellationToken; + (long workflowId, Guid runId, long stepId) = await SeedFailedRunAsync( ct ); + + Dictionary overrides = new( ) { + ["NewVar"] = "hello", + }; + + _ = await _service.RetryAsync( workflowId, runId, stepId, overrides, ct ); + + WorkflowRunVariable? newVar = await _dbContext.Set( ) + .FirstOrDefaultAsync( v => v.WorkflowRunId == runId && v.VariableName == "NewVar", ct ); + + Assert.IsNotNull( newVar ); + Assert.AreEqual( 1, newVar.Version ); + Assert.AreEqual( "hello", newVar.Value ); + Assert.AreEqual( VariableSource.ReExecutionEdit, newVar.Source ); + } + + /// + /// Verifies that retrying a run not in Failed status throws . + /// + [TestMethod] + public async Task RetryAsync_RunNotFailed_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + (long workflowId, Guid runId, long stepId) = await SeedFailedRunAsync( ct ); + + // Transition the run to Running first + WorkflowRun run = await _dbContext.WorkflowRuns.FirstAsync( r => r.Id == runId, ct ); + run.Status = WorkflowRunStatus.Running; + _ = await _dbContext.SaveChangesAsync( ct ); + + _ = await Assert.ThrowsExactlyAsync( ( ) => + _service.RetryAsync( workflowId, runId, stepId, null, ct ) + ); + } + + /// + /// Verifies that retrying with a step that doesn't belong to the workflow throws + /// . + /// + [TestMethod] + public async Task RetryAsync_StepNotInWorkflow_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + (long workflowId, Guid runId, _) = await SeedFailedRunAsync( ct ); + + _ = await Assert.ThrowsExactlyAsync( ( ) => + _service.RetryAsync( workflowId, runId, 99999, null, ct ) + ); + } + + /// + /// Verifies that retrying with a mismatched workflowId (run belongs to a different workflow) + /// throws without modifying the run status. + /// + [TestMethod] + public async Task RetryAsync_WorkflowIdMismatch_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + (_, Guid runId, long stepId) = await SeedFailedRunAsync( ct ); + + long wrongWorkflowId = 99999; + _ = await Assert.ThrowsExactlyAsync( ( ) => + _service.RetryAsync( wrongWorkflowId, runId, stepId, null, ct ) + ); + + // Verify the run status was NOT changed. + WorkflowRun run = await _dbContext.WorkflowRuns.FirstAsync(r => r.Id == runId, ct); + Assert.AreEqual( WorkflowRunStatus.Failed, run.Status ); + } + + /// + /// Verifies that retrying from a step without a Failed execution throws + /// . + /// + [TestMethod] + public async Task RetryAsync_StepNotFailed_Throws( ) { + CancellationToken ct = TestContext.CancellationToken; + (long workflowId, Guid runId, long stepId) = await SeedFailedRunAsync( ct ); + + // Change the execution status to Completed + WorkflowStepExecution exec = await _dbContext.WorkflowStepExecutions + .FirstAsync( e => e.WorkflowRunId == runId && e.StepId == stepId, ct ); + exec.Status = StepExecutionStatus.Completed; + _ = await _dbContext.SaveChangesAsync( ct ); + + _ = await Assert.ThrowsExactlyAsync( ( ) => + _service.RetryAsync( workflowId, runId, stepId, null, ct ) + ); + } + + /// + /// Verifies that retry creates a one-time schedule linked to the workflow. + /// + [TestMethod] + public async Task RetryAsync_CreatesOneTimeSchedule( ) { + CancellationToken ct = TestContext.CancellationToken; + (long workflowId, Guid runId, long stepId) = await SeedFailedRunAsync( ct ); + + RetryFromFailedService.RetryResult result = await _service.RetryAsync( + workflowId, runId, stepId, null, ct + ); + + WorkflowSchedule? link = await _dbContext.WorkflowSchedules + .FirstOrDefaultAsync( ws => ws.ScheduleId == result.ScheduleId, ct ); + + Assert.IsNotNull( link ); + Assert.AreEqual( workflowId, link.WorkflowId ); + Assert.IsTrue( link.IsOneTime ); + Assert.AreEqual( runId, link.WorkflowRunId ); + } + + #region DAG and Repeated-Retry Tests + + /// + /// Seeds a 3-step DAG: A → B → C with B in Failed status and A/C in Completed/Pending. + /// Returns (workflowId, runId, stepAId, stepBId, stepCId). + /// + private async Task<(long WorkflowId, Guid RunId, long StepAId, long StepBId, long StepCId)> SeedDagFailedRunAsync( + CancellationToken ct ) { + + Workflow workflow = new() { Name = "DAG Workflow", Description = "A→B→C" }; + _ = _dbContext.Set( ).Add( workflow ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WerkrTask taskA = new() + { + Name = "Task A", + WorkflowId = workflow.Id, + ActionType = TaskActionType.PowerShellCommand, + Content = "echo A", + TargetTags = ["default"], + }; + WerkrTask taskB = new() + { + Name = "Task B", + WorkflowId = workflow.Id, + ActionType = TaskActionType.PowerShellCommand, + Content = "echo B", + TargetTags = ["default"], + }; + WerkrTask taskC = new() + { + Name = "Task C", + WorkflowId = workflow.Id, + ActionType = TaskActionType.PowerShellCommand, + Content = "echo C", + TargetTags = ["default"], + }; + _dbContext.Set( ).AddRange( taskA, taskB, taskC ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowStep stepA = new() { WorkflowId = workflow.Id, TaskId = taskA.Id, Order = 1 }; + WorkflowStep stepB = new() { WorkflowId = workflow.Id, TaskId = taskB.Id, Order = 2 }; + WorkflowStep stepC = new() { WorkflowId = workflow.Id, TaskId = taskC.Id, Order = 3 }; + _dbContext.WorkflowSteps.AddRange( stepA, stepB, stepC ); + _ = await _dbContext.SaveChangesAsync( ct ); + + // Dependencies: B depends on A, C depends on B + _dbContext.WorkflowStepDependencies.AddRange( + new WorkflowStepDependency { StepId = stepB.Id, DependsOnStepId = stepA.Id }, + new WorkflowStepDependency { StepId = stepC.Id, DependsOnStepId = stepB.Id } + ); + _ = await _dbContext.SaveChangesAsync( ct ); + + Guid runId = Guid.NewGuid(); + WorkflowRun run = new() + { + Id = runId, + WorkflowId = workflow.Id, + StartTime = DateTime.UtcNow.AddMinutes(-5), + EndTime = DateTime.UtcNow.AddMinutes(-1), + Status = WorkflowRunStatus.Failed, + }; + _ = _dbContext.WorkflowRuns.Add( run ); + _ = await _dbContext.SaveChangesAsync( ct ); + + // A = Completed (attempt 1), B = Failed (attempt 1), C = Pending (attempt 1) + _dbContext.WorkflowStepExecutions.AddRange( + new WorkflowStepExecution { WorkflowRunId = runId, StepId = stepA.Id, Attempt = 1, Status = StepExecutionStatus.Completed }, + new WorkflowStepExecution { WorkflowRunId = runId, StepId = stepB.Id, Attempt = 1, Status = StepExecutionStatus.Failed }, + new WorkflowStepExecution { WorkflowRunId = runId, StepId = stepC.Id, Attempt = 1, Status = StepExecutionStatus.Pending } + ); + _ = await _dbContext.SaveChangesAsync( ct ); + + return (workflow.Id, runId, stepA.Id, stepB.Id, stepC.Id); + } + + /// + /// Verifies that retrying from step B in a DAG (A→B→C) resets B and C but not A. + /// + [TestMethod] + public async Task RetryAsync_DagDownstreamReset_ResetsOnlyTargetAndDownstream( ) { + CancellationToken ct = TestContext.CancellationToken; + (long workflowId, Guid runId, long stepAId, long stepBId, long stepCId) = + await SeedDagFailedRunAsync( ct ); + + RetryFromFailedService.RetryResult result = await _service.RetryAsync( + workflowId, runId, stepBId, null, ct + ); + + // B and C should be reset (2 steps) + Assert.AreEqual( 2, result.ResetStepCount ); + + // Step A should NOT have a new execution — still just its original Completed one + List execA = await _dbContext.WorkflowStepExecutions + .Where(e => e.WorkflowRunId == runId && e.StepId == stepAId) + .ToListAsync(ct); + Assert.HasCount( 1, execA ); + Assert.AreEqual( StepExecutionStatus.Completed, execA[0].Status ); + + // Step B should have attempt 1 (Failed) + attempt 2 (Pending) + List execB = await _dbContext.WorkflowStepExecutions + .Where(e => e.WorkflowRunId == runId && e.StepId == stepBId) + .OrderBy(e => e.Attempt) + .ToListAsync(ct); + Assert.HasCount( 2, execB ); + Assert.AreEqual( StepExecutionStatus.Failed, execB[0].Status ); + Assert.AreEqual( 1, execB[0].Attempt ); + Assert.AreEqual( StepExecutionStatus.Pending, execB[1].Status ); + Assert.AreEqual( 2, execB[1].Attempt ); + + // Step C should have attempt 1 (Pending, original) + attempt 2 (Pending, retry) + List execC = await _dbContext.WorkflowStepExecutions + .Where(e => e.WorkflowRunId == runId && e.StepId == stepCId) + .OrderBy(e => e.Attempt) + .ToListAsync(ct); + Assert.HasCount( 2, execC ); + Assert.AreEqual( 1, execC[0].Attempt ); + Assert.AreEqual( StepExecutionStatus.Pending, execC[1].Status ); + Assert.AreEqual( 2, execC[1].Attempt ); + } + + /// + /// Verifies that retrying twice increments attempt numbers correctly (1→2→3). + /// + [TestMethod] + public async Task RetryAsync_RepeatedRetry_IncrementsAttempts( ) { + CancellationToken ct = TestContext.CancellationToken; + (long workflowId, Guid runId, long stepId) = await SeedFailedRunAsync( ct ); + + // First retry: attempt 1 (Failed) → creates attempt 2 (Pending) + _ = await _service.RetryAsync( workflowId, runId, stepId, null, ct ); + + // Simulate the retried execution failing again using ExecuteUpdateAsync + // (bypasses the change tracker, same as the service's CAS pattern). + _dbContext.ChangeTracker.Clear( ); + + _ = await _dbContext.WorkflowStepExecutions + .Where( e => e.WorkflowRunId == runId && e.StepId == stepId && e.Attempt == 2 ) + .ExecuteUpdateAsync( s => s.SetProperty( e => e.Status, StepExecutionStatus.Failed ), ct ); + + _ = await _dbContext.WorkflowRuns + .Where( r => r.Id == runId ) + .ExecuteUpdateAsync( s => s + .SetProperty( r => r.Status, WorkflowRunStatus.Failed ) + .SetProperty( r => r.EndTime, DateTime.UtcNow ), ct ); + + // Second retry: should create attempt 3 + _ = await _service.RetryAsync( workflowId, runId, stepId, null, ct ); + + _dbContext.ChangeTracker.Clear( ); + + List allExecs = await _dbContext.WorkflowStepExecutions + .Where(e => e.WorkflowRunId == runId && e.StepId == stepId) + .OrderBy(e => e.Attempt) + .ToListAsync(ct); + + Assert.HasCount( 3, allExecs ); + Assert.AreEqual( 1, allExecs[0].Attempt ); + Assert.AreEqual( StepExecutionStatus.Failed, allExecs[0].Status ); + Assert.AreEqual( 2, allExecs[1].Attempt ); + Assert.AreEqual( StepExecutionStatus.Failed, allExecs[1].Status ); + Assert.AreEqual( 3, allExecs[2].Attempt ); + Assert.AreEqual( StepExecutionStatus.Pending, allExecs[2].Status ); + } + + #endregion +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleCalculatorTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleCalculatorTests.cs index 01cff0a..8413d92 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleCalculatorTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleCalculatorTests.cs @@ -281,13 +281,13 @@ public void CalculateOccurrences_UtcDt_ReturnsSingleOccurrence( ) { schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -308,13 +308,13 @@ public void CalculateOccurrences_LocalDt_ReturnsSingleOccurrence( ) { schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -335,13 +335,13 @@ public void CalculateOccurrences_UnspecDt_ReturnsSingleOccurrence( ) { schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -361,13 +361,13 @@ public void CalculateOccurrences_P14Dt_ReturnsSingleOccurrence( ) { schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -387,13 +387,13 @@ public void CalculateOccurrences_P13Dt_ReturnsSingleOccurrence( ) { schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -413,13 +413,13 @@ public void CalculateOccurrences_P1245Dt_ReturnsSingleOccurrence( ) { schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -439,13 +439,13 @@ public void CalculateOccurrences_M330Dt_ReturnsSingleOccurrence( ) { schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -465,9 +465,8 @@ public void CalculateOccurrences_EndOfWindow_ReturnsEmptyEnumerable( ) { schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -487,9 +486,8 @@ public void CalculateOccurrences_AfterEndOfWindow_ReturnsEmptyEnumerable( ) { schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -515,13 +513,13 @@ public void CalculateOccurrences_UtcDtRepeatOptionsIntervalGtrThanDuration_Retur schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -543,17 +541,17 @@ public void CalculateOccurrences_LocalDtRepeatOptionsIntervalHalfDuration_Return schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 3, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( lastRepeatTime, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -574,17 +572,17 @@ public void CalculateOccurrences_UnspecDtMaxRepeatOptionsIntervalMaxDurationMax_ schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 2, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddMinutes( IntervalMaxDurationMax.RepeatIntervalMinutes ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -606,13 +604,13 @@ public void CalculateOccurrences_P14DtRepeatOptionsMinIntervalMaxDuration_Repeat schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1440, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -634,17 +632,17 @@ public void CalculateOccurrences_P13DtRepeatOptionsIntervalHourlyMaxDuration_Rep schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 24, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddHours( 23 ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -666,17 +664,17 @@ public void CalculateOccurrences_P1245DtRepeatOptionsMinIntervalMinDuration_Repe schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 3156585, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( EndOfWindow.AddSeconds( -59 ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -697,13 +695,13 @@ public void CalculateOccurrences_M330DtRepeatOptionsMinIntervalZeroDuration_Does schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -725,9 +723,9 @@ public void CalculateOccurrences_UtcDtRepeatOptionsInterval15mDuration2h_Repeats schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 9, - occurrences.Count( ) + occurrences ); DateTime occurrenceTime = schedule.StartDateTime!.UtcTime; foreach (DateTime occurrence in occurrences) { @@ -756,9 +754,8 @@ public void CalculateOccurrences_EOWRepeatOptions_ReturnsEmptyEnumerable( ) { schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -783,9 +780,8 @@ public void CalculateOccurrences_UtcDtExpBeforeStart_ReturnsEmptyEnumerable( ) { schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -806,9 +802,9 @@ public void CalculateOccurrences_UtcDtExp1dAfter_ReturnsOneOccurrence( ) { schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); } @@ -831,9 +827,8 @@ public void CalculateOccurrences_UtcDtRepeatOptionsIntervalGtrThanDurationExpBef schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -856,17 +851,17 @@ public void CalculateOccurrences_LocalDTRepeatOptionsIntervalHalfDurationExp1dAf schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 3, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( lastRepeatTime, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -889,17 +884,17 @@ public void CalculateOccurrences_UnspecDTRepeatOptionsMaxIntervalMaxDurationExp1 schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 2, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddMinutes( IntervalMaxDurationMax.RepeatIntervalMinutes ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -922,13 +917,13 @@ public void CalculateOccurrences_P14DTRepeatOptionsMinIntervalMaxDurationExp1MAf schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1440, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -951,17 +946,17 @@ public void CalculateOccurrences_P13DTRepeatOptionsIntervalHourlyMaxDurationExp6 schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 24, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddHours( 23 ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -984,17 +979,17 @@ public void CalculateOccurrences_P1245DTRepeatOptionsMinIntervalMinDurationExp1y schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 527040, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddYears( 1 ).AddMinutes( -1 ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -1017,13 +1012,13 @@ public void CalculateOccurrences_M330DTRepeatOptionsMinIntervalZeroDurationExp2y schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -1046,9 +1041,9 @@ public void CalculateOccurrences_UtcRepeatOptions15mInterval2hDurationExpAfterWi schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 9, - occurrences.Count( ) + occurrences ); DateTime occurrenceTime = schedule.StartDateTime!.UtcTime; foreach (DateTime occurrence in occurrences) { @@ -1079,9 +1074,8 @@ public void CalculateOccurrences_EowUtcDtExpAfterEnd_ReturnsEmptyEnumerable( ) { schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -1107,13 +1101,13 @@ public void CalculateOccurrences_UtcDtNegativeDays_ReturnsSingleOccurrence( ) { schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -1135,13 +1129,13 @@ public void CalculateOccurrences_LocalDtZeroDays_ReturnsSingleOccurrence( ) { schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -1169,17 +1163,17 @@ public void CalculateOccurrences_UnspecDtEveryDay_ReturnsOneOccurrencePerDayUnti schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 2191, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( endOfWindowDate, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -1206,17 +1200,17 @@ public void CalculateOccurrences_P14DtEveryThreeDays_ReturnsOneOccurrenceEveryTh schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 731, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( endOfWindowDate, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -1238,18 +1232,18 @@ public void CalculateOccurrences_P13DtEverySevenDays_ReturnsOneOccurrenceEverySe schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 314, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); // Compare date only — UTC hour may differ from start due to DST transitions in Samoa timezone. Assert.AreEqual( endOfWindowDate, - DateOnly.FromDateTime( occurrences.Last( ) ) + DateOnly.FromDateTime( occurrences[occurrences.Count - 1] ) ); } @@ -1276,17 +1270,17 @@ public void CalculateOccurrences_P1245DtEveryEightDays_ReturnsOneOccurrenceEvery schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 275, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( endOfWindowDate, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -1313,17 +1307,17 @@ public void CalculateOccurrences_M330DtEveryFourteenDays_ReturnsOneOccurrenceEve schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 157, - occurrences.LongCount( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( endOfWindowDate, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -1350,17 +1344,17 @@ public void CalculateOccurrences_P1245DtEveryThirtyDays_ReturnsOneOccurrenceEver schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 74, - occurrences.LongCount( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( endOfWindowDate, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -1381,9 +1375,8 @@ public void CalculateOccurrences_EndOfWindowNegativeDays_ReturnsEmptyEnumerable( schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -1404,9 +1397,8 @@ public void CalculateOccurrences_AfterEndOfWindowEveryDay_ReturnsEmptyEnumerable schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -1427,9 +1419,9 @@ public void CalculateOccurrences_UtcDtEveryDay_Returns2192OccurrencesEachOneDayA schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 2192, - occurrences.Count( ) + occurrences ); DateTime occurrenceTime = schedule.StartDateTime!.UtcTime; foreach (DateTime occurrence in occurrences) { @@ -1460,9 +1452,9 @@ public void CalculateOccurrences_UtcRepeatOptionsIntervalHalfDurationOccursEvery schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 6576, - occurrences.Count( ) + occurrences ); DateTime occurrenceTime = schedule.StartDateTime!.UtcTime; int count = 0; @@ -1499,9 +1491,8 @@ public void CalculateOccurrences_UtcDtDrEveryDayExpBeforeStart_ReturnsEmptyEnume schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -1523,9 +1514,9 @@ public void CalculateOccurrences_UtcDtDrEveryThreeDaysExp1dAfter_ReturnsOneOccur schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); } @@ -1549,9 +1540,8 @@ public void CalculateOccurrences_UtcDtRepeatOptionsIntervalGtrThanDurationDrEver schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -1576,17 +1566,17 @@ public void CalculateOccurrences_LocalDTRepeatOptionsIntervalHalfDurationDrNegat schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 3, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( lastRepeatTime, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -1610,17 +1600,17 @@ public void CalculateOccurrences_UnspecDTRepeatOptionsMaxIntervalMaxDurationDrZe schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 2, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddMinutes( IntervalMaxDurationMax.RepeatIntervalMinutes ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -1650,17 +1640,17 @@ public void CalculateOccurrences_P14DTRepeatOptionsMinIntervalMaxDurationDrEvery schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 44640, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( finalDt, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -1684,17 +1674,17 @@ public void CalculateOccurrences_P13DTRepeatOptionsIntervalHourlyMaxDurationDrEv schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1464, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddDays( 181 ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -1718,17 +1708,17 @@ public void CalculateOccurrences_P1245DTRepeatOptionsMinIntervalMinDurationDrEve schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 527040, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddYears( 1 ).AddMinutes( -1 ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -1752,17 +1742,17 @@ public void CalculateOccurrences_M330DTRepeatOptionsMinIntervalZeroDurationDrEve schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 92, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddDays( 728 ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -1786,9 +1776,9 @@ public void CalculateOccurrences_UtcRepeatOptions15mInterval2hDurationDrEveryFou schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1413, - occurrences.Count( ) + occurrences ); DateTime occurrenceTime = schedule.StartDateTime!.UtcTime; int count = 0; @@ -1824,9 +1814,8 @@ public void CalculateOccurrences_EowUtcDtDrEveryThirtyDaysExpAfterEnd_ReturnsEmp schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -1851,13 +1840,13 @@ public void CalculateOccurrences_UtcDtWrNegativeWeeksEveryDay_ReturnsSingleOccur schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -1878,13 +1867,13 @@ public void CalculateOccurrences_LocalDtWrZeroWeeksMondays_ReturnsSingleOccurren schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); } @@ -1998,7 +1987,7 @@ public void CalculateOccurrences_P1245DtWrEveryFourWeeksFriSatSun_ReturnsOneOccu ); Assert.AreEqual( endOfWindowDate, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -2026,17 +2015,17 @@ public void CalculateOccurrences_M330DtWrEverySixWeeksMTWThF_ReturnsThreeOccurre schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 263, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( endOfWindowDate, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); CalculateOccurrences_SimpleWeeklyRecurrence( schedule, @@ -2070,17 +2059,17 @@ public void CalculateOccurrences_P1245DtWrEveryEightWeeksWednesday_ReturnsOneOcc schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 40, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( endOfWindowDate, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); CalculateOccurrences_SimpleWeeklyRecurrence( schedule, @@ -2107,9 +2096,8 @@ public void CalculateOccurrences_EndOfWindowWrNegativeWeeksEveryDay_ReturnsEmpty schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -2130,9 +2118,8 @@ public void CalculateOccurrences_AfterEndOfWindowWrZeroWeeksMondays_ReturnsEmpty schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -2216,9 +2203,8 @@ public void CalculateOccurrences_UtcDtWrEveryThreeWeeksTuThSatExpBeforeStart_Ret schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -2241,9 +2227,9 @@ public void CalculateOccurrences_UtcDtWrEveryFourWeeksFriSatSunExp1dAfter_Return schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1, - occurrences.Count( ) + occurrences ); } @@ -2267,9 +2253,8 @@ public void CalculateOccurrences_UtcDtRepeatOptionsIntervalGtrThanDurationWrEver schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -2294,17 +2279,17 @@ public void CalculateOccurrences_LocalDTRepeatOptionsIntervalHalfDurationWrEvery schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 3, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( lastRepeatTime, - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -2328,17 +2313,17 @@ public void CalculateOccurrences_UnspecDTRepeatOptionsMaxIntervalMaxDurationWrNe schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 2, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddMinutes( IntervalMaxDurationMax.RepeatIntervalMinutes ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -2362,17 +2347,17 @@ public void CalculateOccurrences_P14DTRepeatOptionsMinIntervalMaxDurationWrZeroW schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1440, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddMinutes( 1439 ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -2396,17 +2381,17 @@ public void CalculateOccurrences_P13DTRepeatOptionsIntervalHourlyMaxDurationWrEv schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 4368, - occurrences.Count( ) + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddDays( 182 ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -2431,17 +2416,17 @@ public void CalculateOccurrences_P1245DTRepeatOptionsMinIntervalMinDurationWrEve schedule, EndOfWindow ); - Assert.AreEqual( - totalScheduleTime.TotalMinutes, - occurrences.Count( ) + Assert.HasCount( + (int)totalScheduleTime.TotalMinutes, + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddYears( 1 ).AddMinutes( -1 ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -2467,17 +2452,17 @@ public void CalculateOccurrences_P1245DTRepeatOptionsMinIntervalMaxDurationWrEve EndOfWindow ); // DST offset change causes a 60-minute gap - Assert.AreEqual( - totalScheduleTime.TotalMinutes - 60, - occurrences.Count( ) + Assert.HasCount( + (int)(totalScheduleTime.TotalMinutes - 60), + occurrences ); Assert.AreEqual( schedule.StartDateTime!.UtcTime, - occurrences.First( ) + occurrences[0] ); Assert.AreEqual( schedule.StartDateTime!.UtcTime.AddYears( 1 ).AddMinutes( -1 ), - occurrences.Last( ) + occurrences[occurrences.Count - 1] ); } @@ -2534,7 +2519,7 @@ public void CalculateOccurrences_UtcRepeatOptions15mInterval2hDurationWrEveryFou int count = -8; Assert.AreEqual( occurrenceTime, - occurrences.First( ) + occurrences[0] ); foreach (DateTime occurrence in occurrences) { Assert.AreEqual( @@ -2574,9 +2559,8 @@ public void CalculateOccurrences_EowUtcDtWrEverySixWeeksMTWThFExpAfterEnd_Return schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -2600,9 +2584,9 @@ public void CalculateOccurrences_EowUtcDtWrEveryHundredSevenWeeksOnFriExpAfterEn schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 36, - occurrences.Count( ) + occurrences ); } @@ -2671,9 +2655,9 @@ int validationCount calculatedOccurrences, "validationCount must match the number of calculatedOccurrences." ); - Assert.AreEqual( + Assert.HasCount( validationCount, - occurrences.Count( ), + occurrences, "SimpleWeeklyRecurrence calculatedOccurrences must match the number of the input occurrences." ); for (int i = 0; i < calculatedOccurrences.Count; i++) { @@ -2706,9 +2690,8 @@ public void CalculateOccurrences_MonthlyRecurrenceExpAfterEnd_ReturnsEmptyEnumer schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -2729,9 +2712,9 @@ public void CalculateOccurrences_MonthlyRecurrenceJanuaryDayNum1_ReturnsSixOccur schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 6, - occurrences.Count( ) + occurrences ); int year = 2020; foreach (DateTime occurrence in occurrences) { @@ -2781,9 +2764,9 @@ public void CalculateOccurrences_MonthlyRecurrenceDecemberDayNum27_ReturnsSixDec schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 7, - occurrences.Count( ) + occurrences ); int year = 2020; int count = 0; @@ -3335,9 +3318,8 @@ public void CalculateOccurrences_ExpirationBeforeStartUtcIntervalGreaterThanDura schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -3516,9 +3498,9 @@ public void CalculateOccurrences_ExpirationAfterEndOfWindowInterval15MinDuration schedule, EndOfWindow ); - Assert.AreEqual( + Assert.HasCount( 1953, - occurrences.Count( ) + occurrences ); } @@ -3542,9 +3524,8 @@ public void CalculateOccurrences_ExpirationBeforeStartUtcIntervalGreaterThanDura schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } @@ -3750,9 +3731,8 @@ public void CalculateOccurrences_ExpirationBeforeStartUtcIntervalGreaterThanDura schedule, EndOfWindow ); - Assert.AreEqual( - 0, - occurrences.Count( ) + Assert.IsEmpty( + occurrences ); } diff --git a/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleServiceTests.cs index f9f2dc3..c35e225 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Scheduling/ScheduleServiceTests.cs @@ -34,6 +34,8 @@ public class ScheduleServiceTests { /// public TestContext TestContext { get; set; } = null!; + private static readonly int[] s_expected = [1, 15]; + /// /// Initializes an in-memory SQLite database, creates the schema, and instantiates the /// with a under test. @@ -250,7 +252,7 @@ public async Task CreateAsync_WithMonthlyDayNum_PersistsRecurrence( ) { Assert.IsNotNull( created.MonthlyRecurrence ); CollectionAssert.AreEqual( - new[] { 1, 15 }, + s_expected, created.MonthlyRecurrence!.DayNumbers ); Assert.AreEqual( diff --git a/src/Test/Werkr.Tests.Data/Unit/Tasks/AgentResolverTests.cs b/src/Test/Werkr.Tests.Data/Unit/Tasks/AgentResolverTests.cs index 93f50b5..43b12f9 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Tasks/AgentResolverTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Tasks/AgentResolverTests.cs @@ -96,7 +96,7 @@ public void TestCleanup( ) { /// /// Creates a with the specified name, status, and tags for test seeding. /// - private RegisteredConnection MakeConnection( + private static RegisteredConnection MakeConnection( string name, ConnectionStatus status, params string[] tags diff --git a/src/Test/Werkr.Tests.Data/Unit/Tasks/TaskServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Tasks/TaskServiceTests.cs index a4e1d5c..3f996dd 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Tasks/TaskServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Tasks/TaskServiceTests.cs @@ -144,17 +144,21 @@ public async Task Create_RequiresContent( ) { } /// - /// Verifies that creating a task with no target tags throws . + /// Verifies that creating a task with empty target tags succeeds. + /// Tags are optional — the UI warns if empty but the API allows it. /// [TestMethod] - public async Task Create_RequiresTargetTags( ) { + public async Task Create_AllowsEmptyTargetTags( ) { WerkrTask task = MakeTask( ); task.TargetTags = []; - _ = await Assert.ThrowsExactlyAsync( ( ) => _service.CreateAsync( + WerkrTask created = await _service.CreateAsync( task, TestContext.CancellationToken - ) ); + ); + + Assert.IsNotNull( created ); + Assert.IsEmpty( created.TargetTags ); } /// diff --git a/src/Test/Werkr.Tests.Data/Unit/Workflows/ControlStatementConverterTests.cs b/src/Test/Werkr.Tests.Data/Unit/Workflows/ControlStatementConverterTests.cs new file mode 100644 index 0000000..58f29ef --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Workflows/ControlStatementConverterTests.cs @@ -0,0 +1,233 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Werkr.Data; +using Werkr.Data.Entities.Tasks; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Tests.Data.Unit.Workflows; + +/// +/// Tests that the ControlStatementStringConverter in +/// correctly round-trips enum values through the database +/// as strings, including backward-compatible reading of the legacy "Sequential" value. +/// +[TestClass] +public class ControlStatementConverterTests { + private SqliteConnection _connection = null!; + private SqliteWerkrDbContext _dbContext = null!; + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .UseSnakeCaseNamingConvention( ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + } + + [TestCleanup] + public void TestCleanup( ) { + _dbContext?.Dispose( ); + _connection?.Dispose( ); + } + + /// + /// Creates a workflow step with , saves, reloads, + /// and verifies it round-trips correctly and is stored as the string "Default". + /// + [TestMethod] + public async Task Default_RoundTrips_AsDefaultString( ) { + CancellationToken ct = TestContext.CancellationToken; + + // Arrange — create a workflow and task to host the step + Workflow workflow = new( ) { Name = "RoundTrip_WF", Description = "test" }; + _ = _dbContext.Workflows.Add( workflow ); + WerkrTask task = new( ) { Name = "RoundTrip_Task", ActionType = TaskActionType.ShellCommand, Content = "echo test", TargetTags = ["test"] }; + _ = _dbContext.Tasks.Add( task ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowStep step = new( ) { + WorkflowId = workflow.Id, + TaskId = task.Id, + Order = 0, + ControlStatement = ControlStatement.Default, + }; + _ = _dbContext.WorkflowSteps.Add( step ); + _ = await _dbContext.SaveChangesAsync( ct ); + + // Detach so the next query hits the database + _dbContext.ChangeTracker.Clear( ); + + // Act — reload from DB + WorkflowStep loaded = await _dbContext.WorkflowSteps.SingleAsync( s => s.Id == step.Id, ct ); + + // Assert — enum value round-trips + Assert.AreEqual( ControlStatement.Default, loaded.ControlStatement ); + + // Assert — raw string in the database is "Default" + long stepId = step.Id; + string? raw = await _dbContext.Database + .SqlQuery( $"SELECT control_statement AS Value FROM workflow_steps WHERE id = {stepId}" ) + .SingleAsync( ct ); + Assert.AreEqual( "Default", raw ); + } + + /// + /// Verifies that every non-Default enum member round-trips through the database correctly. + /// + [TestMethod] + [DataRow( ControlStatement.If, "If" )] + [DataRow( ControlStatement.Else, "Else" )] + [DataRow( ControlStatement.ElseIf, "ElseIf" )] + [DataRow( ControlStatement.While, "While" )] + [DataRow( ControlStatement.Do, "Do" )] + public async Task AllEnumValues_RoundTrip_Correctly( ControlStatement value, string expectedString ) { + CancellationToken ct = TestContext.CancellationToken; + + Workflow workflow = new( ) { Name = $"RoundTrip_{value}", Description = "test" }; + _ = _dbContext.Workflows.Add( workflow ); + WerkrTask task = new( ) { Name = $"Task_{value}", ActionType = TaskActionType.ShellCommand, Content = "echo test", TargetTags = ["test"] }; + _ = _dbContext.Tasks.Add( task ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowStep step = new( ) { + WorkflowId = workflow.Id, + TaskId = task.Id, + Order = 0, + ControlStatement = value, + ConditionExpression = value is ControlStatement.If or ControlStatement.ElseIf or ControlStatement.While or ControlStatement.Do + ? "$? -eq $true" : null, + }; + _ = _dbContext.WorkflowSteps.Add( step ); + _ = await _dbContext.SaveChangesAsync( ct ); + + _dbContext.ChangeTracker.Clear( ); + + WorkflowStep loaded = await _dbContext.WorkflowSteps.SingleAsync( s => s.Id == step.Id, ct ); + Assert.AreEqual( value, loaded.ControlStatement ); + + long stepId = step.Id; + string? raw = await _dbContext.Database + .SqlQuery( $"SELECT control_statement AS Value FROM workflow_steps WHERE id = {stepId}" ) + .SingleAsync( ct ); + Assert.AreEqual( expectedString, raw ); + } + + /// + /// Verifies that the legacy "Sequential" string value in the database is correctly + /// read as by the converter. + /// + [TestMethod] + public async Task LegacySequential_ReadsAs_Default( ) { + CancellationToken ct = TestContext.CancellationToken; + + Workflow workflow = new( ) { Name = "Legacy_WF", Description = "test" }; + _ = _dbContext.Workflows.Add( workflow ); + WerkrTask task = new( ) { Name = "Legacy_Task", ActionType = TaskActionType.ShellCommand, Content = "echo test", TargetTags = ["test"] }; + _ = _dbContext.Tasks.Add( task ); + _ = await _dbContext.SaveChangesAsync( ct ); + + // Insert a step with "Default" first (to get a valid row) + WorkflowStep step = new( ) { + WorkflowId = workflow.Id, + TaskId = task.Id, + Order = 0, + ControlStatement = ControlStatement.Default, + }; + _ = _dbContext.WorkflowSteps.Add( step ); + _ = await _dbContext.SaveChangesAsync( ct ); + + // Manually overwrite the stored string to the legacy "Sequential" value + long stepId = step.Id; + _ = await _dbContext.Database.ExecuteSqlAsync( + $"UPDATE workflow_steps SET control_statement = 'Sequential' WHERE id = {stepId}", ct ); + + _dbContext.ChangeTracker.Clear( ); + + // Act — reload via EF + WorkflowStep loaded = await _dbContext.WorkflowSteps.SingleAsync( s => s.Id == step.Id, ct ); + + // Assert — converter maps "Sequential" → Default + Assert.AreEqual( ControlStatement.Default, loaded.ControlStatement ); + } + + /// + /// Verifies that all legacy database string values from the old ControlStatement enum + /// are correctly mapped to the current enum members by the converter. + /// + [TestMethod] + [DataRow( "Parallel", ControlStatement.Default )] + [DataRow( "ConditionalIf", ControlStatement.If )] + [DataRow( "ConditionalElseIf", ControlStatement.ElseIf )] + [DataRow( "ConditionalWhile", ControlStatement.While )] + [DataRow( "ConditionalDo", ControlStatement.Do )] + [DataRow( "ConditionalElse", ControlStatement.Else )] + public async Task LegacyValues_ReadAs_CorrectEnum( string legacyString, ControlStatement expected ) { + CancellationToken ct = TestContext.CancellationToken; + + Workflow workflow = new() { Name = $"Legacy_{legacyString}", Description = "test" }; + _ = _dbContext.Workflows.Add( workflow ); + WerkrTask task = new() { Name = $"Task_{legacyString}", ActionType = TaskActionType.ShellCommand, Content = "echo test", TargetTags = ["test"] }; + _ = _dbContext.Tasks.Add( task ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowStep step = new() + { + WorkflowId = workflow.Id, + TaskId = task.Id, + Order = 0, + ControlStatement = ControlStatement.Default, + }; + _ = _dbContext.WorkflowSteps.Add( step ); + _ = await _dbContext.SaveChangesAsync( ct ); + + long stepId = step.Id; + _ = await _dbContext.Database.ExecuteSqlAsync( + $"UPDATE workflow_steps SET control_statement = {legacyString} WHERE id = {stepId}", ct ); + + _dbContext.ChangeTracker.Clear( ); + + WorkflowStep loaded = await _dbContext.WorkflowSteps.SingleAsync(s => s.Id == step.Id, ct); + Assert.AreEqual( expected, loaded.ControlStatement ); + } + + /// + /// Verifies that an unrecognized string in the database falls back to . + /// + [TestMethod] + public async Task UnknownString_FallsBackTo_Default( ) { + CancellationToken ct = TestContext.CancellationToken; + + Workflow workflow = new() { Name = "Unknown_WF", Description = "test" }; + _ = _dbContext.Workflows.Add( workflow ); + WerkrTask task = new() { Name = "Unknown_Task", ActionType = TaskActionType.ShellCommand, Content = "echo test", TargetTags = ["test"] }; + _ = _dbContext.Tasks.Add( task ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowStep step = new() + { + WorkflowId = workflow.Id, + TaskId = task.Id, + Order = 0, + ControlStatement = ControlStatement.Default, + }; + _ = _dbContext.WorkflowSteps.Add( step ); + _ = await _dbContext.SaveChangesAsync( ct ); + + long stepId = step.Id; + _ = await _dbContext.Database.ExecuteSqlAsync( + $"UPDATE workflow_steps SET control_statement = 'Bogus' WHERE id = {stepId}", ct ); + + _dbContext.ChangeTracker.Clear( ); + + WorkflowStep loaded = await _dbContext.WorkflowSteps.SingleAsync(s => s.Id == step.Id, ct); + Assert.AreEqual( ControlStatement.Default, loaded.ControlStatement ); + } +} diff --git a/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowServiceTests.cs b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowServiceTests.cs index 51e9fae..1b9b200 100644 --- a/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowServiceTests.cs +++ b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowServiceTests.cs @@ -625,6 +625,96 @@ await _service.GetTopologicalLevelsAsync( ); // Level 1: B, C } + /// + /// Verifies that three independent steps (no dependencies) are all grouped at level 0. + /// + [TestMethod] + public async Task GetTopologicalLevelsAsync_ThreeIndependentSteps_AllAtLevelZero( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "ThreeIndependent", Description = string.Empty }; + _ = await _service.CreateAsync( workflow, ct ); + await SeedTaskAsync( ct ); + + _ = await _service.AddStepAsync( workflow.Id, new WorkflowStep { TaskId = 1, Order = 0 }, ct ); + _ = await _service.AddStepAsync( workflow.Id, new WorkflowStep { TaskId = 1, Order = 1 }, ct ); + _ = await _service.AddStepAsync( workflow.Id, new WorkflowStep { TaskId = 1, Order = 2 }, ct ); + + IReadOnlyList> levels = + await _service.GetTopologicalLevelsAsync( workflow.Id, ct ); + + Assert.HasCount( 1, levels ); + Assert.HasCount( 3, levels[0] ); + } + + /// + /// Verifies a diamond-shaped DAG: A → B, A → C, B → D, C → D produces + /// three levels: [A], [B, C], [D]. B and C are parallelizable at level 1. + /// + [TestMethod] + public async Task GetTopologicalLevelsAsync_DiamondDag_GroupsCorrectly( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "Diamond", Description = string.Empty }; + _ = await _service.CreateAsync( workflow, ct ); + await SeedTaskAsync( ct ); + + WorkflowStep a = await _service.AddStepAsync( workflow.Id, new WorkflowStep { TaskId = 1, Order = 0 }, ct ); + WorkflowStep b = await _service.AddStepAsync( workflow.Id, new WorkflowStep { TaskId = 1, Order = 1 }, ct ); + WorkflowStep c = await _service.AddStepAsync( workflow.Id, new WorkflowStep { TaskId = 1, Order = 2 }, ct ); + WorkflowStep d = await _service.AddStepAsync( workflow.Id, new WorkflowStep { TaskId = 1, Order = 3 }, ct ); + + await _service.AddStepDependencyAsync( b.Id, a.Id, ct ); + await _service.AddStepDependencyAsync( c.Id, a.Id, ct ); + await _service.AddStepDependencyAsync( d.Id, b.Id, ct ); + await _service.AddStepDependencyAsync( d.Id, c.Id, ct ); + + IReadOnlyList> levels = + await _service.GetTopologicalLevelsAsync( workflow.Id, ct ); + + Assert.HasCount( 3, levels ); + Assert.HasCount( 1, levels[0] ); // Level 0: A + Assert.HasCount( 2, levels[1] ); // Level 1: B, C (parallel) + Assert.HasCount( 1, levels[2] ); // Level 2: D + } + + /// + /// Verifies that If/ElseIf/Else steps at the same level are separated from Default steps + /// for independent chain-bound processing. Both sets share the same topological level + /// but the execution engine will partition them for sequential vs. parallel execution. + /// + [TestMethod] + public async Task GetTopologicalLevelsAsync_MixedControlStatements_SameLevelGrouped( ) { + CancellationToken ct = TestContext.CancellationToken; + Workflow workflow = new( ) { Name = "MixedControl", Description = string.Empty }; + _ = await _service.CreateAsync( workflow, ct ); + await SeedTaskAsync( ct ); + + // Root step + WorkflowStep root = await _service.AddStepAsync( + workflow.Id, new WorkflowStep { TaskId = 1, Order = 0 }, ct ); + + // Default step depends on root + WorkflowStep defaultStep = await _service.AddStepAsync( + workflow.Id, new WorkflowStep { TaskId = 1, Order = 1, ControlStatement = ControlStatement.Default }, ct ); + await _service.AddStepDependencyAsync( defaultStep.Id, root.Id, ct ); + + // If step depends on root + WorkflowStep ifStep = await _service.AddStepAsync( + workflow.Id, new WorkflowStep { TaskId = 1, Order = 2, ControlStatement = ControlStatement.If, ConditionExpression = "$? -eq $true" }, ct ); + await _service.AddStepDependencyAsync( ifStep.Id, root.Id, ct ); + + IReadOnlyList> levels = + await _service.GetTopologicalLevelsAsync( workflow.Id, ct ); + + Assert.HasCount( 2, levels ); + Assert.HasCount( 1, levels[0] ); // Level 0: root + Assert.HasCount( 2, levels[1] ); // Level 1: defaultStep + ifStep (execution engine partitions them) + + // Verify both steps are at level 1 with their correct control statements + HashSet controlStatements = [.. levels[1].Select( s => s.ControlStatement )]; + Assert.Contains( ControlStatement.Default, controlStatements ); + Assert.Contains( ControlStatement.If, controlStatements ); + } + // ── Control Flow Validation ── /// diff --git a/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowVariableTests.cs b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowVariableTests.cs new file mode 100644 index 0000000..f9be562 --- /dev/null +++ b/src/Test/Werkr.Tests.Data/Unit/Workflows/WorkflowVariableTests.cs @@ -0,0 +1,445 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Werkr.Data; +using Werkr.Data.Entities.Tasks; +using Werkr.Data.Entities.Workflows; + +namespace Werkr.Tests.Data.Unit.Workflows; + +/// +/// Unit tests for workflow variable entities, including design-time +/// definitions, runtime append-only versioning, and +/// enum usage. +/// +[TestClass] +public class WorkflowVariableTests { + + /// In-memory SQLite connection kept open for the lifetime of the test. + private SqliteConnection _connection = null!; + /// The test database context. + private SqliteWerkrDbContext _dbContext = null!; + + /// + /// Gets or sets the MSTest for the current test run. + /// + public TestContext TestContext { get; set; } = null!; + + /// + /// Opens an in-memory SQLite database and creates the schema. + /// + [TestInitialize] + public void TestInit( ) { + _connection = new SqliteConnection( "DataSource=:memory:" ); + _connection.Open( ); + + DbContextOptions options = new DbContextOptionsBuilder( ) + .UseSqlite( _connection ) + .Options; + + _dbContext = new SqliteWerkrDbContext( options ); + _ = _dbContext.Database.EnsureCreated( ); + } + + /// Disposes the context and connection after each test. + [TestCleanup] + public void TestCleanup( ) { + _dbContext?.Dispose( ); + _connection?.Dispose( ); + } + + // ── WorkflowVariable Tests ────────────────────────────────────────────────── + + /// + /// Verifies that a variable can be created and retrieved with all properties intact. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WorkflowVariable_CreateAndRetrieve_RoundTrips( ) { + long workflowId = await SeedWorkflowAsync( TestContext.CancellationToken ); + CancellationToken ct = TestContext.CancellationToken; + + WorkflowVariable variable = new( ) { + WorkflowId = workflowId, + Name = "test_var", + Description = "A test variable", + DefaultValue = "{\"key\": \"value\"}", + }; + _ = _dbContext.WorkflowVariables.Add( variable ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowVariable loaded = await _dbContext.WorkflowVariables + .FirstAsync( v => v.Id == variable.Id, ct ); + + Assert.AreEqual( "test_var", loaded.Name ); + Assert.AreEqual( "A test variable", loaded.Description ); + Assert.AreEqual( "{\"key\": \"value\"}", loaded.DefaultValue ); + Assert.AreEqual( workflowId, loaded.WorkflowId ); + } + + /// + /// Verifies that two variables with the same name on the same workflow violate + /// the unique index and cause a . + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WorkflowVariable_DuplicateName_ThrowsDbUpdateException( ) { + long workflowId = await SeedWorkflowAsync( TestContext.CancellationToken ); + CancellationToken ct = TestContext.CancellationToken; + + _ = _dbContext.WorkflowVariables.Add( new WorkflowVariable { + WorkflowId = workflowId, + Name = "dup_var", + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + _ = _dbContext.WorkflowVariables.Add( new WorkflowVariable { + WorkflowId = workflowId, + Name = "dup_var", + } ); + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _dbContext.SaveChangesAsync( ct ) ); + } + + /// + /// Verifies that variables with the same name on different workflows are allowed. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WorkflowVariable_SameNameDifferentWorkflows_Allowed( ) { + CancellationToken ct = TestContext.CancellationToken; + long wf1 = await SeedWorkflowAsync( ct ); + long wf2 = await SeedWorkflowAsync( ct ); + + _ = _dbContext.WorkflowVariables.Add( new WorkflowVariable { + WorkflowId = wf1, + Name = "shared_name", + } ); + _ = _dbContext.WorkflowVariables.Add( new WorkflowVariable { + WorkflowId = wf2, + Name = "shared_name", + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + int count = await _dbContext.WorkflowVariables + .CountAsync( v => v.Name == "shared_name", ct ); + Assert.AreEqual( 2, count ); + } + + /// + /// Verifies that a null default value is acceptable (optional). + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WorkflowVariable_NullDefaultValue_Accepted( ) { + long workflowId = await SeedWorkflowAsync( TestContext.CancellationToken ); + CancellationToken ct = TestContext.CancellationToken; + + WorkflowVariable variable = new( ) { + WorkflowId = workflowId, + Name = "no_default", + DefaultValue = null, + }; + _ = _dbContext.WorkflowVariables.Add( variable ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowVariable loaded = await _dbContext.WorkflowVariables + .FirstAsync( v => v.Id == variable.Id, ct ); + Assert.IsNull( loaded.DefaultValue ); + } + + /// + /// Verifies that deleting a workflow cascades to its variables. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WorkflowVariable_CascadeDeleteWithWorkflow( ) { + long workflowId = await SeedWorkflowAsync( TestContext.CancellationToken ); + CancellationToken ct = TestContext.CancellationToken; + + _ = _dbContext.WorkflowVariables.Add( new WorkflowVariable { + WorkflowId = workflowId, + Name = "cascade_test", + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + Workflow workflow = await _dbContext.Set( ) + .FirstAsync( w => w.Id == workflowId, ct ); + _ = _dbContext.Set( ).Remove( workflow ); + _ = await _dbContext.SaveChangesAsync( ct ); + + int count = await _dbContext.WorkflowVariables.CountAsync( ct ); + Assert.AreEqual( 0, count ); + } + + // ── WorkflowRunVariable Tests ─────────────────────────────────────────────── + + /// + /// Verifies that append-only inserts produce correct version numbering and + /// that the latest value is the highest version. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WorkflowRunVariable_AppendOnly_VersionIncrementsCorrectly( ) { + (long workflowId, Guid runId) = await SeedWorkflowRunAsync( TestContext.CancellationToken ); + CancellationToken ct = TestContext.CancellationToken; + + // Insert three versions + for (int v = 1; v <= 3; v++) { + _ = _dbContext.WorkflowRunVariables.Add( new WorkflowRunVariable { + WorkflowRunId = runId, + VariableName = "counter", + Value = $"{{\"n\": {v}}}", + Version = v, + Source = VariableSource.StepOutput, + Created = DateTime.UtcNow, + } ); + } + _ = await _dbContext.SaveChangesAsync( ct ); + + // Verify 3 rows exist + List rows = await _dbContext.WorkflowRunVariables + .Where( r => r.WorkflowRunId == runId && r.VariableName == "counter" ) + .OrderBy( r => r.Version ) + .ToListAsync( ct ); + + Assert.HasCount( 3, rows ); + Assert.AreEqual( 1, rows[0].Version ); + Assert.AreEqual( 3, rows[2].Version ); + + // Latest = highest version + WorkflowRunVariable? latest = await _dbContext.WorkflowRunVariables + .Where( r => r.WorkflowRunId == runId && r.VariableName == "counter" ) + .OrderByDescending( r => r.Version ) + .FirstOrDefaultAsync( ct ); + + Assert.IsNotNull( latest ); + Assert.AreEqual( 3, latest.Version ); + Assert.AreEqual( "{\"n\": 3}", latest.Value ); + } + + /// + /// Verifies that duplicate (RunId, VariableName, Version) tuples violate the + /// unique index and cause a . + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WorkflowRunVariable_DuplicateVersion_ThrowsDbUpdateException( ) { + (long _, Guid runId) = await SeedWorkflowRunAsync( TestContext.CancellationToken ); + CancellationToken ct = TestContext.CancellationToken; + + _ = _dbContext.WorkflowRunVariables.Add( new WorkflowRunVariable { + WorkflowRunId = runId, + VariableName = "v", + Value = "1", + Version = 1, + Source = VariableSource.Default, + Created = DateTime.UtcNow, + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + _ = _dbContext.WorkflowRunVariables.Add( new WorkflowRunVariable { + WorkflowRunId = runId, + VariableName = "v", + Value = "2", + Version = 1, + Source = VariableSource.StepOutput, + Created = DateTime.UtcNow, + } ); + + _ = await Assert.ThrowsExactlyAsync( + ( ) => _dbContext.SaveChangesAsync( ct ) ); + } + + /// + /// Verifies that the Default VariableSource value is stored correctly. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WorkflowRunVariable_DefaultSource_RoundTrips( ) { + (long _, Guid runId) = await SeedWorkflowRunAsync( TestContext.CancellationToken ); + CancellationToken ct = TestContext.CancellationToken; + + _ = _dbContext.WorkflowRunVariables.Add( new WorkflowRunVariable { + WorkflowRunId = runId, + VariableName = "src_test", + Value = "\"hello\"", + Version = 1, + Source = VariableSource.Default, + Created = DateTime.UtcNow, + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowRunVariable loaded = await _dbContext.WorkflowRunVariables + .FirstAsync( r => r.VariableName == "src_test", ct ); + Assert.AreEqual( VariableSource.Default, loaded.Source ); + } + + /// + /// Verifies that the ManualInput VariableSource value round-trips correctly. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WorkflowRunVariable_ManualInputSource_RoundTrips( ) { + (long _, Guid runId) = await SeedWorkflowRunAsync( TestContext.CancellationToken ); + CancellationToken ct = TestContext.CancellationToken; + + _ = _dbContext.WorkflowRunVariables.Add( new WorkflowRunVariable { + WorkflowRunId = runId, + VariableName = "src_manual", + Value = "\"triggered\"", + Version = 1, + Source = VariableSource.ManualInput, + Created = DateTime.UtcNow, + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowRunVariable loaded = await _dbContext.WorkflowRunVariables + .FirstAsync( r => r.VariableName == "src_manual", ct ); + Assert.AreEqual( VariableSource.ManualInput, loaded.Source ); + } + + /// + /// Verifies that deleting a workflow run cascades to its run variables. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WorkflowRunVariable_CascadeDeleteWithWorkflowRun( ) { + (long _, Guid runId) = await SeedWorkflowRunAsync( TestContext.CancellationToken ); + CancellationToken ct = TestContext.CancellationToken; + + _ = _dbContext.WorkflowRunVariables.Add( new WorkflowRunVariable { + WorkflowRunId = runId, + VariableName = "del_test", + Value = "\"x\"", + Version = 1, + Source = VariableSource.StepOutput, + Created = DateTime.UtcNow, + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowRun run = await _dbContext.Set( ) + .FirstAsync( r => r.Id == runId, ct ); + _ = _dbContext.Set( ).Remove( run ); + _ = await _dbContext.SaveChangesAsync( ct ); + + int count = await _dbContext.WorkflowRunVariables.CountAsync( ct ); + Assert.AreEqual( 0, count ); + } + + /// + /// Verifies that the VariableName is denormalized — it can reference a name + /// that does not exist as a WorkflowVariable definition. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WorkflowRunVariable_DenormalizedName_NoForeignKey( ) { + (long _, Guid runId) = await SeedWorkflowRunAsync( TestContext.CancellationToken ); + CancellationToken ct = TestContext.CancellationToken; + + // Insert a run variable with a name that doesn't match any WorkflowVariable + _ = _dbContext.WorkflowRunVariables.Add( new WorkflowRunVariable { + WorkflowRunId = runId, + VariableName = "nonexistent_var", + Value = "\"phantom\"", + Version = 1, + Source = VariableSource.StepOutput, + Created = DateTime.UtcNow, + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + WorkflowRunVariable loaded = await _dbContext.WorkflowRunVariables + .FirstAsync( r => r.VariableName == "nonexistent_var", ct ); + Assert.AreEqual( "\"phantom\"", loaded.Value ); + } + + /// + /// Verifies that multiple variables on the same run track independently. + /// + [TestMethod] + [Timeout( 10_000, CooperativeCancellation = true )] + public async Task WorkflowRunVariable_MultipleVariables_IndependentVersioning( ) { + (long _, Guid runId) = await SeedWorkflowRunAsync( TestContext.CancellationToken ); + CancellationToken ct = TestContext.CancellationToken; + + _ = _dbContext.WorkflowRunVariables.Add( new WorkflowRunVariable { + WorkflowRunId = runId, + VariableName = "alpha", + Value = "\"a1\"", + Version = 1, + Source = VariableSource.Default, + Created = DateTime.UtcNow, + } ); + _ = _dbContext.WorkflowRunVariables.Add( new WorkflowRunVariable { + WorkflowRunId = runId, + VariableName = "alpha", + Value = "\"a2\"", + Version = 2, + Source = VariableSource.StepOutput, + Created = DateTime.UtcNow, + } ); + _ = _dbContext.WorkflowRunVariables.Add( new WorkflowRunVariable { + WorkflowRunId = runId, + VariableName = "beta", + Value = "\"b1\"", + Version = 1, + Source = VariableSource.Default, + Created = DateTime.UtcNow, + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + + int alphaCount = await _dbContext.WorkflowRunVariables + .CountAsync( r => r.VariableName == "alpha", ct ); + int betaCount = await _dbContext.WorkflowRunVariables + .CountAsync( r => r.VariableName == "beta", ct ); + + Assert.AreEqual( 2, alphaCount ); + Assert.AreEqual( 1, betaCount ); + } + + // ── Seed Helpers ──────────────────────────────────────────────────────────── + + /// Seeds a task (required for workflow steps) and returns the task ID. + private async Task SeedTaskAsync( CancellationToken ct ) { + if (!await _dbContext.Tasks.AnyAsync( ct )) { + _ = _dbContext.Tasks.Add( new WerkrTask { + Name = "SeedTask", + Description = "Test task", + ActionType = TaskActionType.ShellCommand, + Content = "echo hello", + TargetTags = ["test"], + } ); + _ = await _dbContext.SaveChangesAsync( ct ); + } + return await _dbContext.Tasks.Select( t => t.Id ).FirstAsync( ct ); + } + + /// Seeds a minimal workflow and returns its ID. + private async Task SeedWorkflowAsync( CancellationToken ct ) { + _ = await SeedTaskAsync( ct ); + + Workflow workflow = new( ) { + Name = $"TestWorkflow_{Guid.NewGuid( ):N}", + Description = "Test workflow for variable tests", + }; + _ = _dbContext.Set( ).Add( workflow ); + _ = await _dbContext.SaveChangesAsync( ct ); + return workflow.Id; + } + + /// Seeds a workflow and a run, returning both IDs. + private async Task<(long WorkflowId, Guid RunId)> SeedWorkflowRunAsync( CancellationToken ct ) { + long workflowId = await SeedWorkflowAsync( ct ); + + Guid runId = Guid.NewGuid( ); + WorkflowRun run = new( ) { + Id = runId, + WorkflowId = workflowId, + StartTime = DateTime.UtcNow, + Status = WorkflowRunStatus.Running, + }; + _ = _dbContext.Set( ).Add( run ); + _ = await _dbContext.SaveChangesAsync( ct ); + return (workflowId, runId); + } +} diff --git a/src/Test/Werkr.Tests.Data/packages.lock.json b/src/Test/Werkr.Tests.Data/packages.lock.json index e4e968c..4744dce 100644 --- a/src/Test/Werkr.Tests.Data/packages.lock.json +++ b/src/Test/Werkr.Tests.Data/packages.lock.json @@ -52,8 +52,8 @@ }, "Microsoft.AspNetCore.Metadata": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "PtLHFABwDpGhpTMxni8z4W0J2b+y2EVFkpZ8K6A092pbdBdlD3yAgxAZhwLxXl2RKBTuVj5TUGc2voDQ/ghpTA==" + "resolved": "10.0.5", + "contentHash": "nXVB1K4RzyhDHKYWLiq3+aJopJZKO5ojFqHV9PZ74fe4VWM/8itoouqsd2KIqSooIwQ13UDNlPQfN2rWr7hc2A==" }, "Microsoft.CodeCoverage": { "type": "Transitive", @@ -67,315 +67,315 @@ }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "c7Uoz381xnMHNBRB8eHRhGgzUtXbgddlbODhwZRrTSzZWDharp3RkJsFwhxyESbeXhCqmML7VdvjMQ7uu+HreA==" + "resolved": "10.0.5", + "contentHash": "32c58Rnm47Qvhimawf67KO9PytgPz3QoWye7Abapt0Yocw/JnzMiSNj/pRoIKyn8Jxypkv86zxKD4Q/zNTc0Ag==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "lVABgJTyTUNE7Bi0bSu4dWHiCHIXEGzTh/kLh0N07IgU/tIKwTeBPp8tgV4x2iKj4h7iPLo8oXzyHmLDGtAE1g==" + "resolved": "10.0.5", + "contentHash": "ipC4u1VojgEfoIZhtbS2Sx5IluJTP/Jf1hz3yGsxGBgSukYY/CquI6rAjxn5H58CZgVn36qcuPPtNMwZ0AUzMg==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "Pmh60OK9neVr/M0FJwm9hlzm2bD4Kd65SID8E6SP5c90tExNgXwORrlEWl0oGU/ig9ifpNN4PSpIrnHNozlT5w==", + "resolved": "10.0.5", + "contentHash": "uxmFjZEAB/KbsgWFSS4lLqkEHCfXxB2x0UcbiO4e5fCRpFFeTMSx/me6009nYJLu5IKlDwO1POh++P6RilFTDw==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" + "Microsoft.EntityFrameworkCore": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" } }, "Microsoft.EntityFrameworkCore.Sqlite.Core": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bUkOkSwPqvhNlsMMg5dA/PR9S4cVehiHEIZklakRH6JZCFsEBNvUz8kBRGK8Hi6mcaPKUAmVZQkN3moZsBYZLA==", + "resolved": "10.0.5", + "contentHash": "rVH43bcUyZiMn0SnCpVnvFpl4PFxT4GwmuVVLcT4JL0NtzuHY9ymKV+Llb5cjuJ+6+gEl4eixy2rE8nxOPcBSA==", "dependencies": { - "Microsoft.Data.Sqlite.Core": "10.0.3", - "Microsoft.EntityFrameworkCore.Relational": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", + "Microsoft.Data.Sqlite.Core": "10.0.5", + "Microsoft.EntityFrameworkCore.Relational": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyModel": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", "SQLitePCLRaw.core": "2.1.11" } }, "Microsoft.Extensions.AmbientMetadata.Application": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "Oh/FQJrTZqiqZuFktqDCwLFgxIUnNATZx46AwUTf5A/+FmK/TzPf/iwSqMK85QlioLD9ehOxWe0NBfsCSkp5pw==", + "resolved": "10.4.0", + "contentHash": "bovnONzrr/JIc+w343i857rJEb7cQH9UzEjbV5n67agWBEYICGQb8xiqYz5+GoFXp6mKEKLwYCQGttMU1p5yXQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + "Microsoft.Extensions.Configuration": "10.0.4", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.4" } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "5dtXBvI8t3z8pF4tB38JYgi/enCL/DwSXxpqShgFz3SHJ7IzqFIMs6Gu5ik8sNZzcO9qQs3xIDpB3vDamkYG+Q==", + "resolved": "10.0.5", + "contentHash": "k/QDdQ94/0Shi0KfU+e12m73jfQo+3JpErTtgpZfsCIqkvdEEO0XIx6R+iTbN55rNPaNhOqNY4/sB+jZ8XxVPw==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "No4fVh0z30SWqiWFRoA4PNdrEco6OjXvCqRFvlmRgDQqqks2bRDdeavUgWEiAX153ZAwW9loUgbxcvuP4NKQLg==", + "resolved": "10.0.5", + "contentHash": "jUEXmkBUPdOS/MP9areK/sbKhdklq9+tEhvwfxGalZVnmyLUO5rrheNNutUBtvbZ7J8ECkG7/r2KXi/IFC06cA==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Caching.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.Compliance.Abstractions": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "b3gmxtyX0n8bQFuZ679f8QiOWqjeZKFc6XXPUsYNFzMcWi0Q44Ej3pB41N/QQ0maX380kmOMF36bLSBHNThXAg==", + "resolved": "10.4.0", + "contentHash": "4WkknDbVrHNf+S6fwSt1OAXlGJ/G/QrtJlqx4aNzOLmeT3GRyxpGLZn+Q3UV+RMRAF6FfsijEZBg2ZAW8bTAkg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.4", + "Microsoft.Extensions.ObjectPool": "10.0.4" } }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "H1Cjv2xmm7O3iAGmFTcnSM0ZhLQ/7SqefmAvSJoT1PbXoxeYc2fo0mCLn2JlVbr9E6YpoU9q/o0fI9neDJB0xQ==", + "resolved": "10.0.5", + "contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "759UhpKaR5Jsll9kXpkft4z/7tpeF7Dw2rTY/9f9JchaSQTpRFNIPkZFZvoo7fFpbjUaqtDlO5aiGpmQrp/EUA==", + "resolved": "10.0.5", + "contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3" + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "/MLsBbLpwDxsU+7DDNwasf2mKrpMSOWEL377gNZTy5waFkCYvS3GVaLIz6bvikH4rAwHrCOxHw0t/5iCoImYCA==", + "resolved": "10.0.5", + "contentHash": "OhTr0O79dP49734lLTqVveivVX9sDXxbI/8vjELAZTHXqoN90mdpgTAgwicJED42iaHMCcZcK6Bj+8wNyBikaw==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Physical": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Physical": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "2DLOmC0EkB2smVK8lPP1PIKEgL1arE3CMp9XSIQB/Y7ev5nnnyuM/PizKJ6QfLD08QCYoopSC9SFdbYglDomYg==", + "resolved": "10.0.5", + "contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bwGMrRcAMWx2s/RDgja97p27rxSz2pEQW0+rX5cWAUWVETVJ/eyxGfjAl8vuG5a+lckWmPIE+vcuaZNVB5YDdw==" + "resolved": "10.0.5", + "contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA==" }, "Microsoft.Extensions.DependencyInjection.AutoActivation": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "g6/S5rhP1XNBqeDa9zKXri/uDKe6gjzSqGsocuySb1OdVm8fz/M9YcWTJsCw5RHF8xIb/B3NgsfBPQF9Emjk3Q==", + "resolved": "10.4.0", + "contentHash": "ksmUG2SFTcXzYdyoLOdeSM/qYLRGN6qbbSzYVkwMK9xsctfR1hYkUayeOpFCMd7L+QSlYX72mK9wxwdgQxyS4g==", "dependencies": { - "Microsoft.Extensions.Hosting.Abstractions": "10.0.3" + "Microsoft.Extensions.Hosting.Abstractions": "10.0.4" } }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "31kRjr1fgdJO1UZ/AsjL2noqwht+juHMQ8c/oh8CEsDhlM2+0zwVZVsZjxSfOFiPtn5+6kRGuvSbLAufAPT0kA==" + "resolved": "10.0.5", + "contentHash": "xA4kkL+QS6KCAOKz/O0oquHs44Ob8J7zpBCNt3wjkBWDg5aCqfwG8rWWLsg5V86AM0sB849g9JjPjIdksTCIKg==" }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "tc0R6i2T+138taoxFPQXb7Sy/4rtq4ytoJrAt4fNGs6k89mHpEhZnXUNgaFKwwb5Ud5rIUeLC6tfpsuHNwiWqg==", + "resolved": "10.0.5", + "contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", + "resolved": "10.0.5", + "contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" } }, "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "TZAZsAFThNQDqCnWaGxV/X7OPSA7b+kY7wNTPNvPvSWa0jcTbmGVyzIY/feF4OgyAUUdzSnnLW6ri2Q+4keM4A==", + "resolved": "10.4.0", + "contentHash": "1/hQmONMWxRTKXuN0pQShQN9QsqIRTS1G4fdmKW0O9phuVZjyzIROQD9Fbfwyn2t+yvP8SzjatGAPX4jDRfgHg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.4" } }, "Microsoft.Extensions.Features": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "djFt1Jt+2uREWWVQiiA4ilYBDtHHY7nK08c5K8xBD9+XFNw3KDVprylrMkH08bZGK3ZHRAkS7JDV9srfLrcm/g==" + "resolved": "10.0.4", + "contentHash": "7to+nkZO+g/GiGQOBzAcrr8HcG8dXETI/hg58fJju0jPO9p/GvNLAis8kMPTBdsjfeTfslBrgFX9Yx1KRnKDww==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "4TD9AXDRsipTmaemwnjt/DM5Ri0de2JzHQhvZ4woBTjUtL4XrPNsMrOk5oiLJAx1gTrE6pOIhxv+lEde5F6CZA==", + "resolved": "10.0.5", + "contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "8qLl5LXtcj6Z8yPbHAA/a57fvvl9nUCdi59AJFuixcWM4wSuENZ8jjoRATOKs/I4vOi/bDe0d5LqGSSLE634eA==", + "resolved": "10.0.5", + "contentHash": "dMu5kUPSfol1Rqhmr6nWPSmbFjDe9w6bkoKithG17bWTZA0UyKirTatM5mqYUN3mGpNA0MorlusIoVTh6J7o5g==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "oM7pl8uJz8WRPRlh4AGQS61aeV9GOfTu89yqTiRSYyyMuCNVkbNra9zEk7ApyJ/sZrUpbjOZCRHuitCEsTWghg==" + "resolved": "10.0.5", + "contentHash": "mOE3ARusNQR0a5x8YOcnUbfyyXGqoAWQtEc7qFOfNJgruDWQLo39Re+3/Lzj5pLPFuFYj8hN4dgKzaSQDKiOCw==" }, "Microsoft.Extensions.Http": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "M5gWob3dtzlF14oto1lR1ZuSJrR0gGc+obv7zY9LGmX5y3Ndpve29MrrjqJW/m4CFud4TE/KFUuHjjtwxhCO8g==", + "resolved": "10.0.4", + "contentHash": "QRbs+A+WfiGTnV9KFNfWlF+My5euQNZnsvdVMulwRN6C/tEPaF+ZlQfedHoNvFHKLwjQMmqwm4z+TSO9eLvRQw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.4", + "Microsoft.Extensions.Diagnostics": "10.0.4", + "Microsoft.Extensions.Logging": "10.0.4", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4" } }, "Microsoft.Extensions.Http.Diagnostics": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "/xuNWNxI4WLVatiTvaqfLd5ijFhQ/qvE14bOyWxeEWmXJkjh/g2G/5TdzMfoe0afq16OdWLGbrD9gWHo178hbg==", + "resolved": "10.4.0", + "contentHash": "ybx2QcCWROCnUCbSj/IyHXn1c58brjjHzTTbueKgBl/qHsWk69mu25mjQ3oaMsO1I0+EcS6AhVuhIopL2q3IDw==", "dependencies": { - "Microsoft.Extensions.Http": "10.0.3", - "Microsoft.Extensions.Telemetry": "10.3.0" + "Microsoft.Extensions.Http": "10.0.4", + "Microsoft.Extensions.Telemetry": "10.4.0" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "8D9Er1cGXNjNDIB+VLBNHn386L5ls2FoiG9a6o12gyn+GG3w6jdfUhzT8dtBnKcevE7/fsVA8MS3FBgFfClFtQ==", + "resolved": "10.0.5", + "contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.Extensions.DependencyInjection": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "PBlaoYeusaxNYyN4WFjzcXWlUDSvLUPxy/e6oP1SONOOYA/oBWT2uBmFGJMV9VTtXiXXxCB39LqlYWbsWE4UKA==", + "resolved": "10.0.4", + "contentHash": "XPXoOpUnWEh0pV7Vl2DK2wj47y73Krhrve5OkPrvGIWdZ4U2r47WO8hEdv+wKn65Kh4pmDdiWm7Ibo5pZX+vig==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3" + "Microsoft.Extensions.Configuration": "10.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.4", + "Microsoft.Extensions.Configuration.Binder": "10.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.4", + "Microsoft.Extensions.Logging": "10.0.4", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.4" } }, "Microsoft.Extensions.ObjectPool": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "dQKlVXzqflsv5X8iDlAN5YmTL1GcLCrOLKo1s9PNdfjqxeu0S/jmWTfiLGno+8+o1qFL3+VFAH5/ftmypN+sPw==" + "resolved": "10.0.4", + "contentHash": "2pufIFOgNl/yWTOoIC9XgBnO9VxgfAjdRCnVwpE2+ICfcroGnjuEAGzJ5lTdZeAe0HvA31vMBWXtcmGB7TOq3g==" }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "hU6WzGTPvPoLA2ng1ILvWQb3g0qORdlHNsxI8IcPLumJb3suimYUl+bbDzdo1V4KFsvVhnMWzysHpKbZaoDQPQ==", + "resolved": "10.0.5", + "contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "bn6QoBbbvwmzLIFyxrnL2/e+sqoNUOGbHyfWK9DPONMv1mDCYHm/C7MusYASM31b2lUx6OiDmonb3v+dv5t0nA==", + "resolved": "10.0.5", + "contentHash": "BB9uUW3+6Rxu1R97OB1H/13lUF8P2+H1+eDhpZlK30kDh/6E4EKHBUqTp+ilXQmZLzsRErxON8aBSR6WpUKJdg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.Binder": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "GEcpTwo7sUoLGGNTqV1FZEuL+tTD9m81NX/mh099dqGNna07/UGZShKQNZRw4hv6nlliSUwYQgSYc7OR99Jufg==" + "resolved": "10.0.5", + "contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g==" }, "Microsoft.Extensions.Resilience": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "xc0dZuPkBaVIdMlODDppmNY/dxE27wIQ46gTzStoFXO4/yVcOMKlPmtr9vTP4edyXBRizGxPtcAFmqxZ5gPTkQ==", + "resolved": "10.4.0", + "contentHash": "41CCbJJPsDWU6NsmKfANHkfT/+KCBlZZqQ1eBoQhhW0xqGCiWmUlMdi2BoaM/GcwKHX5WiQL/IESROmgk0Owfw==", "dependencies": { - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.3.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.3", - "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0", + "Microsoft.Extensions.Diagnostics": "10.0.4", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.4.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.4", + "Microsoft.Extensions.Telemetry.Abstractions": "10.4.0", "Polly.Extensions": "8.4.2", "Polly.RateLimiting": "8.4.2" } }, "Microsoft.Extensions.ServiceDiscovery.Abstractions": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "2j3SdFGgqFQFP6xQpkbqxt9SdzsT4/FK47RQ7MoCAZnKZiZEDuLDq9PfKLqON8HbGc09jHZ02x0tHIjleVab2A==", + "resolved": "10.4.0", + "contentHash": "HkBb7cdi27tkQiQw1anQFbXe+A3pjRwDKgVbd/DD9fMAO2X9abK0FEyM/tNVXjW3lwOWl2tF+Xij/DqI6i+JTg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.Binder": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Features": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.4", + "Microsoft.Extensions.Configuration.Binder": "10.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.4", + "Microsoft.Extensions.Features": "10.0.4", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4", + "Microsoft.Extensions.Primitives": "10.0.4" } }, "Microsoft.Extensions.Telemetry": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "M1esrIGmwU2JBY0JpwdlUTXTNBXSBFEs+41bYBd59+9/vCaXw+vGhtYcCL+JXeGmxTLUHmuXcKbX/uVCSFcuzA==", + "resolved": "10.4.0", + "contentHash": "AbHleTzdpGPjA6RpOjKVHEYx7SoBRnJ2bwAbbPa3aGB7HiVwBmeTJhBGhtIBiuIW0VpKDS8x+bV5iWqpBRIf4w==", "dependencies": { - "Microsoft.Extensions.AmbientMetadata.Application": "10.3.0", - "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.3.0", - "Microsoft.Extensions.Logging.Configuration": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3", - "Microsoft.Extensions.Telemetry.Abstractions": "10.3.0" + "Microsoft.Extensions.AmbientMetadata.Application": "10.4.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.4.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.4", + "Microsoft.Extensions.ObjectPool": "10.0.4", + "Microsoft.Extensions.Telemetry.Abstractions": "10.4.0" } }, "Microsoft.Extensions.Telemetry.Abstractions": { "type": "Transitive", - "resolved": "10.3.0", - "contentHash": "aKxH6ZsGAewGF8uSXyx1WkjqItwZA+hd1hhQ/4i7o5injCWSdr9vIZ3R3djJfy8OG3xaWK+LZY/+slVvlnwEHw==", + "resolved": "10.4.0", + "contentHash": "3b2uVa4voJfLLg39BPCKQS0ZgnpEZFkKf7YmnMVlM5FQJYBPOuePIQdnEK1/Oxd+w3GscxGYuE7IMOXDwixZtQ==", "dependencies": { - "Microsoft.Extensions.Compliance.Abstractions": "10.3.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.ObjectPool": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.Extensions.Compliance.Abstractions": "10.4.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.ObjectPool": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4" } }, "Microsoft.IdentityModel.Abstractions": { @@ -526,8 +526,8 @@ }, "Npgsql": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "xZAYhPOU2rUIFpV48xsqhCx9vXs6Y+0jX2LCoSEfDFYMw9jtAOUk3iQsCnDLrFIv9NT3JGMihn7nnuZsPKqJmA==", + "resolved": "10.0.2", + "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==", "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "10.0.0" } @@ -668,8 +668,8 @@ "type": "Project", "dependencies": { "Grpc.AspNetCore": "[2.76.0, )", - "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.3, )", - "Microsoft.AspNetCore.OpenApi": "[10.0.3, )", + "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.5, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )", "Microsoft.IdentityModel.JsonWebTokens": "[8.16.0, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.Console": "[6.1.1, )", @@ -685,8 +685,8 @@ "type": "Project", "dependencies": { "Google.Protobuf": "[3.34.0, )", - "Microsoft.AspNetCore.Authorization": "[10.0.3, )", - "Microsoft.Extensions.Configuration.Json": "[10.0.3, )", + "Microsoft.AspNetCore.Authorization": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Json": "[10.0.5, )", "Microsoft.IdentityModel.Tokens": "[8.16.0, )", "Werkr.Common.Configuration": "[1.0.0, )" } @@ -698,8 +698,8 @@ "type": "Project", "dependencies": { "Grpc.Net.Client": "[2.76.0, )", - "Microsoft.Extensions.Hosting.Abstractions": "[10.0.3, )", - "System.Security.Cryptography.ProtectedData": "[10.0.3, )", + "Microsoft.Extensions.Hosting.Abstractions": "[10.0.5, )", + "System.Security.Cryptography.ProtectedData": "[10.0.5, )", "Werkr.Common": "[1.0.0, )", "Werkr.Data": "[1.0.0, )" } @@ -708,17 +708,17 @@ "type": "Project", "dependencies": { "EFCore.NamingConventions": "[10.0.1, )", - "Microsoft.EntityFrameworkCore": "[10.0.3, )", - "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.3, )", - "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.5, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.5, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )", "Werkr.Common": "[1.0.0, )" } }, "werkr.servicedefaults": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Http.Resilience": "[10.3.0, )", - "Microsoft.Extensions.ServiceDiscovery": "[10.3.0, )", + "Microsoft.Extensions.Http.Resilience": "[10.4.0, )", + "Microsoft.Extensions.ServiceDiscovery": "[10.4.0, )", "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.0, )", "OpenTelemetry.Extensions.Hosting": "[1.15.0, )" } @@ -771,134 +771,140 @@ "Microsoft.Extensions.Http": "8.0.0" } }, + "Grpc.Tools": { + "type": "CentralTransitive", + "requested": "[2.78.0, )", + "resolved": "2.78.0", + "contentHash": "6jPG2gHon+w2PczW8jjrCRnW/g9eEfCdd7aK6mDooptWtuPsV3ZxAwKKEx7LGEDVoT4c2SViRl8Yu3L1XiWIIg==" + }, "Microsoft.AspNetCore.Authentication.JwtBearer": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "TBDs8e9y2vJHp14EwNfnIZUNrm6siw8PAAU5laOrYFuGgRxx8oCdxZyfTgp1Oy/icUk9h/XtpYBHPnXIG0f2/g==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "fZzXogChrwQ/SfifQJgeW7AtR8hUv5+LH9oLWjm5OqfnVt3N8MwcMHHMdawvqqdjP79lIZgetnSpj77BLsSI1g==", "dependencies": { "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1" } }, "Microsoft.AspNetCore.Authorization": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pqm2ivtD2bj5f+4KnrGmJsD/iDZkMnJnK/uW/p1bpqKCR316TyWqyhhS5znLGw7QpX2fAWhXU+uQo1Cb89bedA==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "NbFi4wN6fUvZK4AKmixpfx0IvqtVimKEn8ZX28LkzZBVo09YnLbyRrJ1001IVQDLbV+aYpS/cLhVJu5JD0rY5A==", "dependencies": { - "Microsoft.AspNetCore.Metadata": "10.0.3", - "Microsoft.Extensions.Diagnostics": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3" + "Microsoft.AspNetCore.Metadata": "10.0.5", + "Microsoft.Extensions.Diagnostics": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" } }, "Microsoft.AspNetCore.OpenApi": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "SAvSrKDgnY5GDjDAngOXxPhUvEKlTU/0zIq8zidqHvh/xnZBPs0Vc4LqwyvnmnafNnyUaivtRABz4K4wodXfSg==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "vTcxIfOPyfFbYk1g8YcXJfkMnlEWVkSnnjxcZLy60zgwiHMRf2SnZR+9E4HlpwKxgE3yfKMOti8J6WfKuKsw6w==", "dependencies": { "Microsoft.OpenApi": "2.0.0" } }, "Microsoft.Data.Sqlite.Core": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "onD94qHlvteS2S9eg1T7Huehm3x92no4nU1AyDWjSmT6jDBhY8QF0UapwNhFA+1dArzxM10fV7s6uI1EIK2+dw==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "jFYXnh7s0RShCw6Vkf+ReGCw+mVi7ISg1YaEzYCJcXnUifmbW+aqvCsRJuSRj2ZuQ+oqetpjxlZtbpMmk5FKqQ==", "dependencies": { "SQLitePCLRaw.core": "2.1.11" } }, "Microsoft.EntityFrameworkCore": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "CDEImwD4A7BseABJMCpLZnhfFjmPY/bHwhhS70elc6gLI/bYUEOhxWt7PmaNGYGhIEzOnStlCy5QcVb+8dod5Q==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9tNBmK3EpYVGRQLiqP+bqK2m+TD0Gv//4vCzR7ZOgl4FWzCFyOpYdIVka13M4kcBdPdSJcs3wbHr3rmzOqbIMA==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.3", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.5", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" } }, "Microsoft.EntityFrameworkCore.Sqlite": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "pXP9l00ZzsZuS6ajUJFLHi5vt3vVxHaxQXCwRs73tyqNbjPX/4ac114wyfAepMSmUKq0roFFoTZ7h+1K1+iQjQ==", - "dependencies": { - "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.3", - "Microsoft.Extensions.Caching.Memory": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyModel": "10.0.3", - "Microsoft.Extensions.Logging": "10.0.3", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "lxeRviglTkkmzYJVJ600yb6gJjnf5za9v7uH+0byuSXTGv7U8cT6hz7qRTmiGSOfLcl86QFdy2BBKaUFd6NQug==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyModel": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", "SQLitePCLRaw.core": "2.1.11" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "xVDHL0+SIgemfh95fTO9cGLe17TWv/ZP0n7m01z8X6pzt2DmQpucioWR/mYZA1sRlkWnkXzfl0JweLNWmE9WMg==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.3" + "Microsoft.Extensions.Primitives": "10.0.5" } }, "Microsoft.Extensions.Configuration.Json": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "mGGMOA9nkET8OVsQfS41o66eWkckBzNHJK6+5VbLQ2YdyqKphcv27uDZxLf4exSl+5QxLnHkN+W/4qEDgyvCPA==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "brBM/WP0YAUYh2+QqSYVdK8eQHYQTtTEUJXJ+84Zkdo2buGLja9VSrMIhgoeBUU7JBmcskAib8Lb/N83bvxgYQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3" + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5" } }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "GdMpC10Jf6poxSvUJ4lgYpJ5F/kJeaAoJmrPufjBoPYyCTKKY5Dyl0rZA+LBNvFqTq1cZa/lhlptlUhNvU6xrg==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.3", - "Microsoft.Extensions.Logging.Abstractions": "10.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5" } }, "Microsoft.Extensions.Http.Resilience": { "type": "CentralTransitive", - "requested": "[10.3.0, )", - "resolved": "10.3.0", - "contentHash": "P4+s/eUH3dZdn1HnivSL2dh6/Jb0ndLt2l88oQPZ9BYdyb4tSRAsnz4QkJHGfPA9lS/XblI5QYsxEdfkurPvIg==", + "requested": "[10.4.0, )", + "resolved": "10.4.0", + "contentHash": "HbkUsPUC7vLy2TaDbdA9aooW64n9yX4sUppRuiJ1cOzzU1FUW+MVEotm6kYVq6AuUI9xwFSBhRFzA03blmk3VA==", "dependencies": { - "Microsoft.Extensions.Http.Diagnostics": "10.3.0", - "Microsoft.Extensions.ObjectPool": "10.0.3", - "Microsoft.Extensions.Resilience": "10.3.0" + "Microsoft.Extensions.Http.Diagnostics": "10.4.0", + "Microsoft.Extensions.ObjectPool": "10.0.4", + "Microsoft.Extensions.Resilience": "10.4.0" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" } }, "Microsoft.Extensions.ServiceDiscovery": { "type": "CentralTransitive", - "requested": "[10.3.0, )", - "resolved": "10.3.0", - "contentHash": "C7onh6YDQKbZjKmAWEef1RDosjxPxA3PZdLob5lhS1AQuKgw0vTHnCKUA1KAhNlzhyfOPVP6tc0cLIPCDoBvoA==", + "requested": "[10.4.0, )", + "resolved": "10.4.0", + "contentHash": "RznZAH6L4RNvroECT5JpqfFQJjHTn+8N7+ThSgYutbshkuymFeL/uBIZt1CM8LOdpPPhn4//a5fLUah9/k7ayQ==", "dependencies": { - "Microsoft.Extensions.Http": "10.0.3", - "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.3.0" + "Microsoft.Extensions.Http": "10.0.4", + "Microsoft.Extensions.ServiceDiscovery.Abstractions": "10.4.0" } }, "Microsoft.IdentityModel.JsonWebTokens": { @@ -922,13 +928,13 @@ }, "Npgsql.EntityFrameworkCore.PostgreSQL": { "type": "CentralTransitive", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "E2+uSWxSB8LdsUVwPaqRWOcGOP92biry2JEwc0KJMdLJF+aZdczeIdEXVwEyv4nSVMQJH0o8tLhyAMiR6VF0lw==", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "P6EwH0Q4xkaA264iNZDqCPhWt8pscfUGxXazDQg4noBfqjoOlk4hKWfvBjF9ZX3R/9JybRmmJfmxr2iBMj0EpA==", "dependencies": { - "Microsoft.EntityFrameworkCore": "[10.0.0, 11.0.0)", - "Microsoft.EntityFrameworkCore.Relational": "[10.0.0, 11.0.0)", - "Npgsql": "10.0.0" + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.2" } }, "OpenTelemetry.Exporter.OpenTelemetryProtocol": { @@ -997,18 +1003,18 @@ "System.IdentityModel.Tokens.Jwt": { "type": "CentralTransitive", "requested": "[8.16.0, )", - "resolved": "8.0.1", - "contentHash": "GJw3bYkWpOgvN3tJo5X4lYUeIFA2HD293FPUhKmp7qxS+g5ywAb34Dnd3cDAFLkcMohy5XTpoaZ4uAHuw0uSPQ==", + "resolved": "8.16.0", + "contentHash": "rrs2u7DRMXQG2yh0oVyF/vLwosfRv20Ld2iEpYcKwQWXHjfV+gFXNQsQ9p008kR9Ou4pxBs68Q6/9zC8Gi1wjg==", "dependencies": { - "Microsoft.IdentityModel.JsonWebTokens": "8.0.1", - "Microsoft.IdentityModel.Tokens": "8.0.1" + "Microsoft.IdentityModel.JsonWebTokens": "8.16.0", + "Microsoft.IdentityModel.Tokens": "8.16.0" } }, "System.Security.Cryptography.ProtectedData": { "type": "CentralTransitive", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "JCKbH/CN5l0CSoJBILEvJmNQVp5vV+FY3q2ue4K9p4eDT4mFEv0bjTQCV+MD6Qk1b/qk9fWmZZKhG1TklbXw1Q==" + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "kxR4O/8o32eNN3m4qbLe3UifYqeyEpallCyVAsLvL5ZFJVyT3JCb+9du/WHfC09VyJh1Q+p/Gd4+AwM7Rz4acg==" } } } diff --git a/src/Test/Werkr.Tests.Server/ActionParameterRegistryTests.cs b/src/Test/Werkr.Tests.Server/ActionParameterRegistryTests.cs deleted file mode 100644 index 66d113b..0000000 --- a/src/Test/Werkr.Tests.Server/ActionParameterRegistryTests.cs +++ /dev/null @@ -1,177 +0,0 @@ -using Werkr.Server.Services; - -namespace Werkr.Tests.Server; - -/// -/// Unit tests for the class defined in -/// the Werkr.Server project. Validates that the static registry of action -/// form descriptors contains the expected actions, fields, field types, default -/// values, and encoding options used by the server's action parameter system. -/// -[TestClass] -public class ActionParameterRegistryTests { - /// - /// Verifies that the collection - /// contains exactly eleven registered - /// entries representing all supported file and process actions. - /// - [TestMethod] - public void All_Contains_Eleven_Actions( ) { - Assert.HasCount( 11, ActionParameterRegistry.All ); - } - - /// - /// Verifies that the dictionary - /// uses a case-insensitive string comparer, so lookups for keys such as - /// "copyfile", "COPYFILE", and "CopyFile" all succeed. - /// - [TestMethod] - public void Actions_Dictionary_Is_Case_Insensitive( ) { - Assert.IsTrue( ActionParameterRegistry.Actions.ContainsKey( "copyfile" ) ); - Assert.IsTrue( ActionParameterRegistry.Actions.ContainsKey( "COPYFILE" ) ); - Assert.IsTrue( ActionParameterRegistry.Actions.ContainsKey( "CopyFile" ) ); - } - - /// - /// Verifies that the dictionary - /// contains a specific expected action key. - /// - [TestMethod] - [DataRow( "CopyFile" )] - [DataRow( "MoveFile" )] - [DataRow( "RenameFile" )] - [DataRow( "DeleteFile" )] - [DataRow( "CreateFile" )] - [DataRow( "CreateDirectory" )] - [DataRow( "TestExists" )] - [DataRow( "ClearContent" )] - [DataRow( "WriteContent" )] - [DataRow( "StartProcess" )] - [DataRow( "StopProcess" )] - public void Actions_Contains_Expected_Key( string key ) { - Assert.IsTrue( - ActionParameterRegistry.Actions.ContainsKey( key ), - $"Missing action key: {key}" - ); - } - - /// - /// Verifies that every in the registry has - /// at least one , ensuring no action is registered - /// without defining its required parameters. - /// - [TestMethod] - public void Every_Descriptor_Has_At_Least_One_Field( ) { - foreach (ActionFormDescriptor desc in ActionParameterRegistry.All) { - Assert.IsNotEmpty( - desc.Fields, - $"Action '{desc.Key}' has no fields." ); - } - } - - /// - /// Verifies that every has a non-null, - /// non-whitespace and - /// so that the UI can present meaningful labels. - /// - [TestMethod] - public void Every_Descriptor_Has_NonEmpty_DisplayName_And_Description( ) { - foreach (ActionFormDescriptor desc in ActionParameterRegistry.All) { - Assert.IsFalse( - string.IsNullOrWhiteSpace( desc.DisplayName ), - $"Action '{desc.Key}' has empty DisplayName." ); - Assert.IsFalse( - string.IsNullOrWhiteSpace( desc.Description ), - $"Action '{desc.Key}' has empty Description." ); - } - } - - /// - /// Verifies that every whose - /// is has a non-null, - /// non-empty list so that dropdowns in the UI - /// are populated. - /// - [TestMethod] - public void Select_Fields_Have_Options( ) { - foreach (ActionFormDescriptor desc in ActionParameterRegistry.All) { - foreach (FieldDescriptor field in desc.Fields) { - if (field.Type == FieldType.Select) { - Assert.IsNotNull( - field.Options, - $"'{desc.Key}.{field.Name}' is Select but has no Options." ); - Assert.IsNotEmpty( - field.Options, - $"'{desc.Key}.{field.Name}' is Select but Options is empty." ); - } - } - } - } - - /// - /// Verifies that the list - /// contains the common "utf-8" encoding value which is the default for - /// most file operations. - /// - [TestMethod] - public void Encodings_Contains_Utf8( ) { - CollectionAssert.Contains( ActionParameterRegistry.Encodings, "utf-8" ); - } - - /// - /// Verifies that the list - /// contains exactly nine supported character encoding values. - /// - [TestMethod] - public void Encodings_Has_Nine_Values( ) { - Assert.HasCount( 9, ActionParameterRegistry.Encodings ); - } - - /// - /// Verifies that the "CopyFile" action's - /// has exactly four fields named "Source", "Destination", "Overwrite", - /// and "Recursive" in the expected order. - /// - [TestMethod] - public void CopyFile_Has_Expected_Fields( ) { - ActionFormDescriptor desc = ActionParameterRegistry.Actions["CopyFile"]; - Assert.HasCount( 4, desc.Fields ); - Assert.AreEqual( "Source", desc.Fields[0].Name ); - Assert.AreEqual( "Destination", desc.Fields[1].Name ); - Assert.AreEqual( "Overwrite", desc.Fields[2].Name ); - Assert.AreEqual( "Recursive", desc.Fields[3].Name ); - } - - /// - /// Verifies that the "TestExists" action's "Type" field is a - /// with exactly three options: "Any", - /// "File", and "Directory". - /// - [TestMethod] - public void TestExists_Type_Is_Select_With_Three_Options( ) { - ActionFormDescriptor desc = ActionParameterRegistry.Actions["TestExists"]; - FieldDescriptor typeField = desc.Fields[1]; - Assert.AreEqual( "Type", typeField.Name ); - Assert.AreEqual( FieldType.Select, typeField.Type ); - Assert.HasCount( 3, typeField.Options! ); - CollectionAssert.Contains( typeField.Options, "Any" ); - CollectionAssert.Contains( typeField.Options, "File" ); - CollectionAssert.Contains( typeField.Options, "Directory" ); - } - - /// - /// Verifies that the "CreateFile" action's "Encoding" field has a default - /// value of "utf-8", ensuring new files will use UTF-8 encoding unless - /// explicitly overridden. - /// - [TestMethod] - public void CreateFile_Encoding_Default_Is_Utf8( ) { - ActionFormDescriptor desc = ActionParameterRegistry.Actions["CreateFile"]; - FieldDescriptor? enc = null; - foreach (FieldDescriptor f in desc.Fields) { - if (f.Name == "Encoding") { enc = f; break; } - } - Assert.IsNotNull( enc ); - Assert.AreEqual( "utf-8", enc.DefaultValue ); - } -} diff --git a/src/Test/Werkr.Tests.Server/AssemblyAttributes.cs b/src/Test/Werkr.Tests.Server/AssemblyAttributes.cs new file mode 100644 index 0000000..99be6a5 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/AssemblyAttributes.cs @@ -0,0 +1 @@ +[assembly: Parallelize( Workers = 0, Scope = ExecutionScope.ClassLevel )] diff --git a/src/Test/Werkr.Tests.Server/Authorization/AuthorizationAttributeTests.cs b/src/Test/Werkr.Tests.Server/Authorization/AuthorizationAttributeTests.cs index b8dbfb5..135e1e7 100644 --- a/src/Test/Werkr.Tests.Server/Authorization/AuthorizationAttributeTests.cs +++ b/src/Test/Werkr.Tests.Server/Authorization/AuthorizationAttributeTests.cs @@ -1,11 +1,8 @@ -using Microsoft.AspNetCore.Authorization; - namespace Werkr.Tests.Server.Authorization; /// /// Verifies that Blazor pages have the correct configuration. /// These are reflection-based tests that validate server-side authorization gates independent of NavMenu visibility. /// -[TestClass] -public class AuthorizationAttributeTests { +public static class AuthorizationAttributeTests { } diff --git a/src/Test/Werkr.Tests.Server/Authorization/PageAuthorizationTests.cs b/src/Test/Werkr.Tests.Server/Authorization/PageAuthorizationTests.cs index c648a1a..7422096 100644 --- a/src/Test/Werkr.Tests.Server/Authorization/PageAuthorizationTests.cs +++ b/src/Test/Werkr.Tests.Server/Authorization/PageAuthorizationTests.cs @@ -45,7 +45,11 @@ public class PageAuthorizationTests { ["/tasks/create"] = string.Empty, ["/tasks/{Id:long}"] = string.Empty, ["/workflows"] = string.Empty, - ["/workflows/create"] = string.Empty, + ["/workflows/create"] = "Admin,Operator", + ["/workflows/new/dag-editor"] = "Admin,Operator", + ["/workflows/new/edit"] = "Admin,Operator", + ["/workflows/{Id:long}/dag-editor"] = "Admin,Operator", + ["/workflows/{Id:long}/edit"] = "Admin,Operator", ["/workflows/{Id:long}"] = string.Empty, ["/workflows/{WorkflowId:long}/runs"] = string.Empty, ["/workflows/runs/{RunId:guid}"] = string.Empty, diff --git a/src/Test/Werkr.Tests.Server/Components/ActionJsonSerializationTests.cs b/src/Test/Werkr.Tests.Server/Components/ActionJsonSerializationTests.cs new file mode 100644 index 0000000..18ce0c5 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Components/ActionJsonSerializationTests.cs @@ -0,0 +1,617 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Werkr.Common.Models.Actions; + +namespace Werkr.Tests.Server.Components; + +/// +/// Tests that the component's JSON output +/// (camelCase, DictionaryKeyPolicy=CamelCase) round-trips correctly through +/// the API-side deserialization (case-insensitive + enum converter). +/// +/// This validates the critical contract between UI serialization and API consumption +/// for all 27 actions, especially the 8 new complex-type actions. +/// +[TestClass] +public class ActionJsonSerializationTests { + /// + /// Matches ActionParameterEditor.s_jsonOptions (UI serialization). + /// + private static readonly JsonSerializerOptions s_uiOptions = new( ) { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + }; + + /// + /// Matches TaskMapper.s_jsonOptions (API deserialization). + /// + private static readonly JsonSerializerOptions s_apiOptions = new( ) { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter( ) }, + }; + + /// + /// Helper: serialize with UI options, then deserialize with API options. + /// + private static T RoundTrip( object uiData ) { + string json = JsonSerializer.Serialize( uiData, s_uiOptions ); + T? result = JsonSerializer.Deserialize( json, s_apiOptions ); + Assert.IsNotNull( result, $"Deserialization of {typeof( T ).Name} should not return null." ); + return result; + } + + // ── File operations ───────────────────────────────────────────── + + /// + /// CopyFile: Text + Bool fields round-trip. + /// + [TestMethod] + public void CopyFile_RoundTrips( ) { + CopyFileParameters result = RoundTrip( new { + source = "/src/file.txt", + destination = "/dst/file.txt", + overwrite = true, + recursive = false, + } ); + + Assert.AreEqual( "/src/file.txt", result.Source ); + Assert.AreEqual( "/dst/file.txt", result.Destination ); + Assert.IsTrue( result.Overwrite ); + Assert.IsFalse( result.Recursive ); + } + + /// + /// MoveFile: Simple text + bool round-trip. + /// + [TestMethod] + public void MoveFile_RoundTrips( ) { + MoveFileParameters result = RoundTrip( new { + source = "/a", + destination = "/b", + overwrite = false, + } ); + + Assert.AreEqual( "/a", result.Source ); + Assert.AreEqual( "/b", result.Destination ); + Assert.IsFalse( result.Overwrite ); + } + + /// + /// RenameFile round-trip. + /// + [TestMethod] + public void RenameFile_RoundTrips( ) { + RenameFileParameters result = RoundTrip( new { + path = "/old", + newName = "new.txt", + overwrite = true, + } ); + + Assert.AreEqual( "/old", result.Path ); + Assert.AreEqual( "new.txt", result.NewName ); + Assert.IsTrue( result.Overwrite ); + } + + /// + /// DeleteFile round-trip. + /// + [TestMethod] + public void DeleteFile_RoundTrips( ) { + DeleteFileParameters result = RoundTrip( new { + path = "/to-delete", + recursive = true, + force = true, + } ); + + Assert.AreEqual( "/to-delete", result.Path ); + Assert.IsTrue( result.Recursive ); + Assert.IsTrue( result.Force ); + } + + /// + /// CreateFile with Encoding select and optional Content round-trip. + /// + [TestMethod] + public void CreateFile_RoundTrips( ) { + CreateFileParameters result = RoundTrip( new { + path = "/new-file.txt", + content = "Hello World", + overwrite = false, + encoding = "utf-8", + createParentDirectories = true, + } ); + + Assert.AreEqual( "/new-file.txt", result.Path ); + Assert.AreEqual( "Hello World", result.Content ); + Assert.AreEqual( "utf-8", result.Encoding ); + } + + /// + /// CreateDirectory round-trip. + /// + [TestMethod] + public void CreateDirectory_RoundTrips( ) { + CreateDirectoryParameters result = RoundTrip( new { + path = "/new-dir", + } ); + + Assert.AreEqual( "/new-dir", result.Path ); + } + + /// + /// TestExists with PathType enum string round-trip. + /// + [TestMethod] + public void TestExists_RoundTrips( ) { + TestExistsParameters result = RoundTrip( new { + path = "/check", + type = "Directory", + } ); + + Assert.AreEqual( "/check", result.Path ); + } + + // ── Content operations ────────────────────────────────────────── + + /// + /// ClearContent round-trip. + /// + [TestMethod] + public void ClearContent_RoundTrips( ) { + ClearContentParameters result = RoundTrip( new { + path = "/clear.txt", + } ); + + Assert.AreEqual( "/clear.txt", result.Path ); + } + + /// + /// WriteContent with Append and Encoding round-trip. + /// + [TestMethod] + public void WriteContent_RoundTrips( ) { + WriteContentParameters result = RoundTrip( new { + path = "/out.txt", + content = "data", + append = true, + encoding = "utf-16", + } ); + + Assert.AreEqual( "/out.txt", result.Path ); + Assert.AreEqual( "data", result.Content ); + Assert.IsTrue( result.Append ); + Assert.AreEqual( "utf-16", result.Encoding ); + } + + /// + /// ReadContent with MaxBytes round-trip. + /// + [TestMethod] + public void ReadContent_RoundTrips( ) { + ReadContentParameters result = RoundTrip( new { + path = "/read.txt", + encoding = "utf-8", + maxBytes = 4096L, + } ); + + Assert.AreEqual( "/read.txt", result.Path ); + Assert.AreEqual( "utf-8", result.Encoding ); + Assert.AreEqual( 4096L, result.MaxBytes ); + } + + /// + /// FindReplace with all fields round-trip. + /// + [TestMethod] + public void FindReplace_RoundTrips( ) { + FindReplaceParameters result = RoundTrip( new { + path = "/config.xml", + find = "localhost", + replace = "prod", + isRegex = false, + caseSensitive = false, + encoding = "utf-8", + } ); + + Assert.AreEqual( "localhost", result.Find ); + Assert.AreEqual( "prod", result.Replace ); + Assert.IsFalse( result.IsRegex ); + Assert.IsFalse( result.CaseSensitive ); + } + + // ── File information ──────────────────────────────────────────── + + /// + /// GetFileInfo round-trip. + /// + [TestMethod] + public void GetFileInfo_RoundTrips( ) { + GetFileInfoParameters result = RoundTrip( new { + path = "/info.dat", + } ); + + Assert.AreEqual( "/info.dat", result.Path ); + } + + /// + /// ListDirectory with enum fields round-trip. + /// + [TestMethod] + public void ListDirectory_RoundTrips( ) { + ListDirectoryParameters result = RoundTrip( new { + path = "/data", + pattern = "*.csv", + recursive = true, + type = "Directory", + sortBy = "Modified", + } ); + + Assert.AreEqual( "/data", result.Path ); + Assert.AreEqual( "*.csv", result.Pattern ); + Assert.IsTrue( result.Recursive ); + } + + // ── Archive operations ────────────────────────────────────────── + + /// + /// CompressArchive with enum Format and CompressionLevel round-trip. + /// + [TestMethod] + public void CompressArchive_RoundTrips( ) { + CompressArchiveParameters result = RoundTrip( new { + source = "/src", + destination = "/dst.tar.gz", + format = "TarGz", + compressionLevel = "Fastest", + includeBaseDirectory = true, + overwrite = false, + } ); + + Assert.AreEqual( "/src", result.Source ); + Assert.AreEqual( "/dst.tar.gz", result.Destination ); + } + + /// + /// ExpandArchive round-trip. + /// + [TestMethod] + public void ExpandArchive_RoundTrips( ) { + ExpandArchiveParameters result = RoundTrip( new { + source = "/archive.zip", + destination = "/out", + overwrite = true, + format = "Auto", + } ); + + Assert.AreEqual( "/archive.zip", result.Source ); + Assert.AreEqual( "/out", result.Destination ); + Assert.IsTrue( result.Overwrite ); + } + + // ── Process operations ────────────────────────────────────────── + + /// + /// StartProcess with conditional TimeoutMs round-trip. + /// + [TestMethod] + public void StartProcess_RoundTrips( ) { + StartProcessParameters result = RoundTrip( new { + fileName = "dotnet", + arguments = "build", + workingDirectory = "/repo", + waitForExit = true, + timeoutMs = 60000, + } ); + + Assert.AreEqual( "dotnet", result.FileName ); + Assert.AreEqual( "build", result.Arguments ); + Assert.IsTrue( result.WaitForExit ); + Assert.AreEqual( 60000, result.TimeoutMs ); + } + + /// + /// StopProcess round-trip. + /// + [TestMethod] + public void StopProcess_RoundTrips( ) { + StopProcessParameters result = RoundTrip( new { + processName = "notepad", + force = true, + } ); + + Assert.AreEqual( "notepad", result.ProcessName ); + Assert.IsTrue( result.Force ); + } + + // ── Control operations ────────────────────────────────────────── + + /// + /// Delay with double Seconds round-trip. + /// + [TestMethod] + public void Delay_RoundTrips( ) { + DelayParameters result = RoundTrip( new { + seconds = 2.5, + reason = "Wait for it", + } ); + + Assert.AreEqual( 2.5, result.Seconds, 0.001 ); + Assert.AreEqual( "Wait for it", result.Reason ); + } + + // ── Event operations ──────────────────────────────────────────── + + /// + /// WatchFile with enum Mode and numeric defaults round-trip. + /// + [TestMethod] + public void WatchFile_RoundTrips( ) { + WatchFileParameters result = RoundTrip( new { + directory = "/drop", + pattern = "*.csv", + stabilitySeconds = 10, + timeoutSeconds = 600, + pollIntervalMs = 2000, + mode = "ExitQuietly", + usePolling = true, + } ); + + Assert.AreEqual( "/drop", result.Directory ); + Assert.AreEqual( "*.csv", result.Pattern ); + Assert.AreEqual( 10, result.StabilitySeconds ); + Assert.IsTrue( result.UsePolling ); + } + + // ── Iteration ─────────────────────────────────────────────────── + + /// + /// ForEach: simple text field round-trip. + /// + [TestMethod] + public void ForEach_RoundTrips( ) { + ForEachParameters result = RoundTrip( new { + arrayPropertyName = "items", + } ); + + Assert.AreEqual( "items", result.ArrayPropertyName ); + } + + // ── Network operations (new complex types) ────────────────────── + + /// + /// HttpRequest: KeyValueMap (Headers), IntArray (ExpectedStatusCodes), ShowWhen fields. + /// This is the most complex serialization test. + /// + [TestMethod] + public void HttpRequest_RoundTrips_Headers_And_StatusCodes( ) { + HttpRequestParameters result = RoundTrip( new { + url = "https://api.example.com", + method = "POST", + headers = new Dictionary { + ["authorization"] = "Bearer token123", + ["accept"] = "application/json", + }, + body = "{\"key\":\"val\"}", + contentType = "application/json", + timeoutSeconds = 60, + expectedStatusCodes = new[] { 200, 201 }, + followRedirects = true, + } ); + + Assert.AreEqual( "https://api.example.com", result.Url ); + Assert.AreEqual( "POST", result.Method ); + Assert.IsNotNull( result.Headers ); + Assert.HasCount( 2, result.Headers ); + Assert.AreEqual( "{\"key\":\"val\"}", result.Body ); + Assert.AreEqual( "application/json", result.ContentType ); + Assert.AreEqual( 60, result.TimeoutSeconds ); + Assert.HasCount( 2, result.ExpectedStatusCodes ); + CollectionAssert.AreEqual( new[] { 200, 201 }, result.ExpectedStatusCodes ); + Assert.IsTrue( result.FollowRedirects ); + } + + /// + /// DownloadFile: KeyValueMap Headers round-trip. + /// + [TestMethod] + public void DownloadFile_RoundTrips_Headers( ) { + DownloadFileParameters result = RoundTrip( new { + url = "https://example.com/file.zip", + destination = "/downloads/file.zip", + headers = new Dictionary { + ["authorization"] = "Bearer abc", + }, + overwrite = true, + timeoutSeconds = 120, + } ); + + Assert.AreEqual( "https://example.com/file.zip", result.Url ); + Assert.AreEqual( "/downloads/file.zip", result.Destination ); + Assert.IsNotNull( result.Headers ); + Assert.IsNotEmpty( result.Headers ); + Assert.IsTrue( result.Overwrite ); + Assert.AreEqual( 120, result.TimeoutSeconds ); + } + + /// + /// TestConnection: enum Protocol and ShowWhen ExpectedStatusCode round-trip. + /// + [TestMethod] + public void TestConnection_RoundTrips_Protocol_And_StatusCode( ) { + TestConnectionParameters result = RoundTrip( new { + host = "example.com", + port = 443, + protocol = "Https", + timeoutSeconds = 15, + expectedStatusCode = 200, + } ); + + Assert.AreEqual( "example.com", result.Host ); + Assert.AreEqual( 443, result.Port ); + Assert.AreEqual( ConnectionProtocol.Https, result.Protocol ); + Assert.AreEqual( 15, result.TimeoutSeconds ); + Assert.AreEqual( 200, result.ExpectedStatusCode ); + } + + /// + /// UploadFile: KeyValueMap Headers and Select Method round-trip. + /// + [TestMethod] + public void UploadFile_RoundTrips_Headers( ) { + UploadFileParameters result = RoundTrip( new { + filePath = "/data/report.pdf", + url = "https://api.example.com/upload", + method = "PUT", + formFieldName = "document", + headers = new Dictionary { + ["x-api-key"] = "secret", + }, + timeoutSeconds = 600, + } ); + + Assert.AreEqual( "/data/report.pdf", result.FilePath ); + Assert.AreEqual( "https://api.example.com/upload", result.Url ); + Assert.AreEqual( "PUT", result.Method ); + Assert.AreEqual( "document", result.FormFieldName ); + Assert.IsNotNull( result.Headers ); + Assert.AreEqual( 600, result.TimeoutSeconds ); + } + + // ── Notification operations ───────────────────────────────────── + + /// + /// SendEmail: StringArray (To, Cc, Attachments) round-trip. + /// + [TestMethod] + public void SendEmail_RoundTrips_StringArrays( ) { + SendEmailParameters result = RoundTrip( new { + smtpHost = "smtp.example.com", + port = 587, + useSsl = true, + credentialName = "smtp-cred", + from = "noreply@example.com", + to = new[] { "user1@example.com", "user2@example.com" }, + cc = new[] { "cc@example.com" }, + subject = "Test Email", + body = "

Hello

", + isHtml = true, + attachments = new[] { "/path/to/file.pdf" }, + } ); + + Assert.AreEqual( "smtp.example.com", result.SmtpHost ); + Assert.AreEqual( 587, result.Port ); + Assert.IsTrue( result.UseSsl ); + Assert.AreEqual( "noreply@example.com", result.From ); + Assert.HasCount( 2, result.To ); + Assert.AreEqual( "user1@example.com", result.To[0] ); + Assert.IsNotNull( result.Cc ); + Assert.HasCount( 1, result.Cc ); + Assert.AreEqual( "Test Email", result.Subject ); + Assert.IsTrue( result.IsHtml ); + Assert.IsNotNull( result.Attachments ); + Assert.HasCount( 1, result.Attachments ); + } + + /// + /// SendWebhook: KeyValueMap Headers and optional Payload round-trip. + /// + [TestMethod] + public void SendWebhook_RoundTrips_Headers( ) { + SendWebhookParameters result = RoundTrip( new { + url = "https://hooks.example.com/notify", + payload = "{\"text\":\"done\"}", + headers = new Dictionary { + ["authorization"] = "Bearer webhook-token", + }, + timeoutSeconds = 15, + } ); + + Assert.AreEqual( "https://hooks.example.com/notify", result.Url ); + Assert.AreEqual( "{\"text\":\"done\"}", result.Payload ); + Assert.IsNotNull( result.Headers ); + Assert.AreEqual( 15, result.TimeoutSeconds ); + } + + // ── Data operations ───────────────────────────────────────────── + + /// + /// TransformJson: ObjectArray (Operations) with sub-objects round-trip. + /// This validates that nested objects serialize/deserialize correctly through + /// the UI→API JSON contract. + /// + [TestMethod] + public void TransformJson_RoundTrips_Operations( ) { + TransformJsonParameters result = RoundTrip( new { + inputPath = "/in.json", + outputPath = "/out.json", + operations = new[] { + new { type = "Extract", path = "/name", value = (string?)null }, + new { type = "Set", path = "/status", value = (string?)"\"active\"" }, + new { type = "Delete", path = "/temp", value = (string?)null }, + new { type = "Merge", path = "/config", value = (string?)"{\"debug\":true}" }, + }, + } ); + + Assert.AreEqual( "/in.json", result.InputPath ); + Assert.AreEqual( "/out.json", result.OutputPath ); + Assert.HasCount( 4, result.Operations ); + Assert.AreEqual( JsonTransformType.Extract, result.Operations[0].Type ); + Assert.AreEqual( "/name", result.Operations[0].Path ); + Assert.AreEqual( JsonTransformType.Set, result.Operations[1].Type ); + Assert.AreEqual( "\"active\"", result.Operations[1].Value ); + Assert.AreEqual( JsonTransformType.Delete, result.Operations[2].Type ); + Assert.AreEqual( JsonTransformType.Merge, result.Operations[3].Type ); + } + + // ── Edge Cases ────────────────────────────────────────────────── + + /// + /// Verifies that null optional fields are handled correctly (not included + /// in serialized JSON when null, and deserialized as null on the API side). + /// + [TestMethod] + public void Null_Optional_Fields_Handled_Correctly( ) { + HttpRequestParameters result = RoundTrip( new { + url = "https://example.com", + method = "GET", + } ); + + Assert.IsNull( result.Headers, "Optional Headers should be null when not provided." ); + Assert.IsNull( result.Body, "Optional Body should be null when not provided." ); + Assert.IsNull( result.OutputFilePath, "Optional OutputFilePath should be null." ); + } + + /// + /// Verifies that default values in parameter records are preserved when + /// the field is not present in the serialized JSON. + /// + [TestMethod] + public void Default_Values_Preserved_When_Not_Serialized( ) { + // Only provide required fields + HttpRequestParameters result = RoundTrip( new { + url = "https://example.com", + } ); + + Assert.AreEqual( "GET", result.Method, "Default Method should be GET." ); + Assert.AreEqual( 30, result.TimeoutSeconds, "Default TimeoutSeconds should be 30." ); + Assert.IsFalse( result.FollowRedirects, "Default FollowRedirects should be false." ); + } + + /// + /// Empty dictionary serialized from UI should deserialize as empty (or null depending on type). + /// + [TestMethod] + public void Empty_Dictionary_Roundtrips( ) { + DownloadFileParameters result = RoundTrip( new { + url = "https://example.com/file", + destination = "/out", + headers = new Dictionary( ), + } ); + + // Empty dict may be empty or null depending on JSON settings + if (result.Headers is not null) { + Assert.HasCount( 0, result.Headers ); + } + } +} diff --git a/src/Test/Werkr.Tests.Server/Components/ActionParameterEditorTests.cs b/src/Test/Werkr.Tests.Server/Components/ActionParameterEditorTests.cs new file mode 100644 index 0000000..4bfff68 --- /dev/null +++ b/src/Test/Werkr.Tests.Server/Components/ActionParameterEditorTests.cs @@ -0,0 +1,691 @@ +using System.Text.Json; +using Bunit; +using Microsoft.AspNetCore.Components; +using Werkr.Common.Models.Actions; +using Werkr.Server.Components.Shared; + +namespace Werkr.Tests.Server.Components; + +/// +/// bUnit tests for . Verifies action selection, +/// field rendering, validation, conditional visibility, JSON mode toggling, +/// and two-way parameter binding. +/// +[TestClass] +public class ActionParameterEditorTests : BunitContext { + /// + /// Shared JSON options matching the component's internal serializer. + /// + private static readonly JsonSerializerOptions s_jsonOptions = new( ) { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + }; + + // ── Action Selection ──────────────────────────────────────────── + + /// + /// Verifies that the action dropdown renders an optgroup for each + /// category in . + /// + [TestMethod] + public void Renders_Optgroups_For_Each_Category( ) { + IRenderedComponent cut = Render( ); + + IReadOnlyList optgroups = cut.FindAll( "select#actionSubType optgroup" ); + Assert.HasCount( ActionRegistry.Categories.Count, optgroups, "Should render one optgroup per category." ); + } + + /// + /// Verifies that all 31 action options appear in the dropdown. + /// + [TestMethod] + public void Renders_All_ThirtyOne_Actions_In_Dropdown( ) { + IRenderedComponent cut = Render( ); + + // All

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 (-F~+zj@!V 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